Skip to content

Commit a46b16f

Browse files
brechtvlwxiaoguang
andauthored
Edit file workflow for creating a fork and proposing changes (#34240)
When viewing a file that the user can't edit because they can't write to the branch, the new, upload, patch, edit and delete functionality is no longer disabled. If no user fork of the repository exists, there is now a page to create one. It will automatically create a fork with a single branch matching the one being viewed, and a unique repository name will be automatically picked. When a fork exists, but it's archived, a mirror or the user can't write code to it, there will instead be a message explaining the situation. If the usable fork exists, a message will appear at the top of the edit page explaining that the changes will be applied to a branch in the fork. The base repository branch will be pushed to a new branch to the fork, and then the edits will be applied on top. The suggestion to fork happens when accessing /_edit/, so that for example online documentation can have an "edit this page" link to the base repository that does the right thing. Also includes changes to properly report errors when trying to commit to a new branch that is protected, and when trying to commit to an existing branch when choosing the new branch option. Resolves #9017, #20882 --------- Co-authored-by: Brecht Van Lommel <[email protected]> Co-authored-by: wxiaoguang <[email protected]>
1 parent 1748045 commit a46b16f

26 files changed

+740
-419
lines changed

models/repo/repo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ func (repo *Repository) AllowsPulls(ctx context.Context) bool {
653653

654654
// CanEnableEditor returns true if repository meets the requirements of web editor.
655655
func (repo *Repository) CanEnableEditor() bool {
656-
return !repo.IsMirror
656+
return !repo.IsMirror && !repo.IsArchived
657657
}
658658

659659
// DescriptionHTML does special handles to description and return HTML string.

options/locale/locale_en-US.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,13 @@ editor.revert = Revert %s onto:
13991399
editor.failed_to_commit = Failed to commit changes.
14001400
editor.failed_to_commit_summary = Error Message:
14011401
1402+
editor.fork_create = Fork Repository to Propose Changes
1403+
editor.fork_create_description = You can not edit this repository directly. Instead you can create a fork, make edits and create a pull request.
1404+
editor.fork_edit_description = You can not edit this repository directly. The changes will be written to your fork <b>%s</b>, so you can create a pull request.
1405+
editor.fork_not_editable = You have forked this repository but your fork is not editable.
1406+
editor.fork_failed_to_push_branch = Failed to push branch %s to your repository.
1407+
editor.fork_branch_exists = Branch "%s" already exists in your fork, please choose a new branch name.
1408+
14021409
commits.desc = Browse source code change history.
14031410
commits.commits = Commits
14041411
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.

routers/web/repo/editor.go

Lines changed: 106 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"code.gitea.io/gitea/modules/charset"
1717
"code.gitea.io/gitea/modules/git"
1818
"code.gitea.io/gitea/modules/httplib"
19+
"code.gitea.io/gitea/modules/log"
1920
"code.gitea.io/gitea/modules/markup"
2021
"code.gitea.io/gitea/modules/setting"
2122
"code.gitea.io/gitea/modules/templates"
@@ -39,26 +40,36 @@ const (
3940
editorCommitChoiceNewBranch string = "commit-to-new-branch"
4041
)
4142

42-
func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
43+
func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
4344
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
4445
if cleanedTreePath != ctx.Repo.TreePath {
4546
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
4647
if ctx.Req.URL.RawQuery != "" {
4748
redirectTo += "?" + ctx.Req.URL.RawQuery
4849
}
4950
ctx.Redirect(redirectTo)
50-
return
51+
return nil
5152
}
5253

53-
commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer)
54+
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
5455
if err != nil {
55-
ctx.ServerError("PrepareCommitFormBehaviors", err)
56-
return
56+
ctx.ServerError("PrepareCommitFormOptions", err)
57+
return nil
58+
}
59+
60+
if commitFormOptions.NeedFork {
61+
ForkToEdit(ctx)
62+
return nil
63+
}
64+
65+
if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() {
66+
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable")
67+
ctx.NotFound(nil)
5768
}
5869

5970
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
6071
ctx.Data["TreePath"] = ctx.Repo.TreePath
61-
ctx.Data["CommitFormBehaviors"] = commitFormBehaviors
72+
ctx.Data["CommitFormOptions"] = commitFormOptions
6273

6374
// for online editor
6475
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
@@ -69,33 +80,35 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
6980
// form fields
7081
ctx.Data["commit_summary"] = ""
7182
ctx.Data["commit_message"] = ""
72-
ctx.Data["commit_choice"] = util.Iif(commitFormBehaviors.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
73-
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, ctx.Repo.Repository)
83+
ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
84+
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
7485
ctx.Data["last_commit"] = ctx.Repo.CommitID
86+
return commitFormOptions
7587
}
7688

7789
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
7890
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path
79-
ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(treePath)
91+
ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/"))
8092
}
8193

82-
type parsedEditorCommitForm[T any] struct {
83-
form T
84-
commonForm *forms.CommitCommonForm
85-
CommitFormBehaviors *context.CommitFormBehaviors
86-
TargetBranchName string
87-
GitCommitter *files_service.IdentityOptions
94+
type preparedEditorCommitForm[T any] struct {
95+
form T
96+
commonForm *forms.CommitCommonForm
97+
CommitFormOptions *context.CommitFormOptions
98+
OldBranchName string
99+
NewBranchName string
100+
GitCommitter *files_service.IdentityOptions
88101
}
89102

90-
func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
103+
func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
91104
commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
92105
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
93106
commitMessage += "\n\n" + body
94107
}
95108
return commitMessage
96109
}
97110

98-
func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *parsedEditorCommitForm[T] {
111+
func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] {
99112
form := web.GetForm(ctx).(T)
100113
if ctx.HasError() {
101114
ctx.JSONError(ctx.GetErrMsg())
@@ -105,15 +118,22 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
105118
commonForm := form.GetCommitCommonForm()
106119
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath)
107120

108-
commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer)
121+
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
109122
if err != nil {
110-
ctx.ServerError("PrepareCommitFormBehaviors", err)
123+
ctx.ServerError("PrepareCommitFormOptions", err)
124+
return nil
125+
}
126+
if commitFormOptions.NeedFork {
127+
// It shouldn't happen, because we should have done the checks in the "GET" request. But just in case.
128+
ctx.JSONError(ctx.Locale.TrString("error.not_found"))
111129
return nil
112130
}
113131

114132
// check commit behavior
115-
targetBranchName := util.Iif(commonForm.CommitChoice == editorCommitChoiceNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
116-
if targetBranchName == ctx.Repo.BranchName && !commitFormBehaviors.CanCommitToBranch {
133+
fromBaseBranch := ctx.FormString("from_base_branch")
134+
commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != ""
135+
targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
136+
if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch {
117137
ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
118138
return nil
119139
}
@@ -125,40 +145,73 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
125145
return nil
126146
}
127147

128-
return &parsedEditorCommitForm[T]{
129-
form: form,
130-
commonForm: commonForm,
131-
CommitFormBehaviors: commitFormBehaviors,
132-
TargetBranchName: targetBranchName,
133-
GitCommitter: gitCommitter,
148+
if commitToNewBranch {
149+
// if target branch exists, we should stop
150+
targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
151+
if err != nil {
152+
ctx.ServerError("IsBranchExist", err)
153+
return nil
154+
} else if targetBranchExists {
155+
if fromBaseBranch != "" {
156+
ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName))
157+
} else {
158+
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName))
159+
}
160+
return nil
161+
}
162+
}
163+
164+
oldBranchName := ctx.Repo.BranchName
165+
if fromBaseBranch != "" {
166+
err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName)
167+
if err != nil {
168+
log.Error("Unable to editorPushBranchToForkedRepository: %v", err)
169+
ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName))
170+
return nil
171+
}
172+
// we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch
173+
oldBranchName = targetBranchName
174+
}
175+
176+
return &preparedEditorCommitForm[T]{
177+
form: form,
178+
commonForm: commonForm,
179+
CommitFormOptions: commitFormOptions,
180+
OldBranchName: oldBranchName,
181+
NewBranchName: targetBranchName,
182+
GitCommitter: gitCommitter,
134183
}
135184
}
136185

137186
// redirectForCommitChoice redirects after committing the edit to a branch
138-
func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCommitForm[T], treePath string) {
187+
func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) {
188+
// when editing a file in a PR, it should return to the origin location
189+
if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
190+
ctx.JSONRedirect(returnURI)
191+
return
192+
}
193+
139194
if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
140195
// Redirect to a pull request when possible
141196
redirectToPullRequest := false
142-
repo, baseBranch, headBranch := ctx.Repo.Repository, ctx.Repo.BranchName, parsed.TargetBranchName
143-
if repo.UnitEnabled(ctx, unit.TypePullRequests) {
144-
redirectToPullRequest = true
145-
} else if parsed.CommitFormBehaviors.CanCreateBasePullRequest {
197+
repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
198+
if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
146199
redirectToPullRequest = true
147200
baseBranch = repo.BaseRepo.DefaultBranch
148201
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
149202
repo = repo.BaseRepo
203+
} else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
204+
redirectToPullRequest = true
150205
}
151206
if redirectToPullRequest {
152207
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch))
153208
return
154209
}
155210
}
156211

157-
returnURI := ctx.FormString("return_uri")
158-
if returnURI == "" || !httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
159-
returnURI = util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.TargetBranchName), util.PathEscapeSegments(treePath))
160-
}
161-
ctx.JSONRedirect(returnURI)
212+
// redirect to the newly updated file
213+
redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath))
214+
ctx.JSONRedirect(redirectTo)
162215
}
163216

164217
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) {
@@ -268,7 +321,7 @@ func EditFile(ctx *context.Context) {
268321
func EditFilePost(ctx *context.Context) {
269322
editorAction := ctx.PathParam("editor_action")
270323
isNewFile := editorAction == "_new"
271-
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
324+
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
272325
if ctx.Written() {
273326
return
274327
}
@@ -292,8 +345,8 @@ func EditFilePost(ctx *context.Context) {
292345

293346
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
294347
LastCommitID: parsed.form.LastCommit,
295-
OldBranch: ctx.Repo.BranchName,
296-
NewBranch: parsed.TargetBranchName,
348+
OldBranch: parsed.OldBranchName,
349+
NewBranch: parsed.NewBranchName,
297350
Message: parsed.GetCommitMessage(defaultCommitMessage),
298351
Files: []*files_service.ChangeRepoFile{
299352
{
@@ -308,7 +361,7 @@ func EditFilePost(ctx *context.Context) {
308361
Committer: parsed.GitCommitter,
309362
})
310363
if err != nil {
311-
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
364+
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
312365
return
313366
}
314367

@@ -327,16 +380,16 @@ func DeleteFile(ctx *context.Context) {
327380

328381
// DeleteFilePost response for deleting file
329382
func DeleteFilePost(ctx *context.Context) {
330-
parsed := parseEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
383+
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
331384
if ctx.Written() {
332385
return
333386
}
334387

335388
treePath := ctx.Repo.TreePath
336389
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
337390
LastCommitID: parsed.form.LastCommit,
338-
OldBranch: ctx.Repo.BranchName,
339-
NewBranch: parsed.TargetBranchName,
391+
OldBranch: parsed.OldBranchName,
392+
NewBranch: parsed.NewBranchName,
340393
Files: []*files_service.ChangeRepoFile{
341394
{
342395
Operation: "delete",
@@ -349,38 +402,38 @@ func DeleteFilePost(ctx *context.Context) {
349402
Committer: parsed.GitCommitter,
350403
})
351404
if err != nil {
352-
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
405+
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
353406
return
354407
}
355408

356409
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
357-
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.TargetBranchName, treePath)
410+
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
358411
redirectForCommitChoice(ctx, parsed, redirectTreePath)
359412
}
360413

361414
func UploadFile(ctx *context.Context) {
362415
ctx.Data["PageIsUpload"] = true
363-
upload.AddUploadContext(ctx, "repo")
364416
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
365-
366-
prepareEditorCommitFormOptions(ctx, "_upload")
417+
opts := prepareEditorCommitFormOptions(ctx, "_upload")
367418
if ctx.Written() {
368419
return
369420
}
421+
upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
422+
370423
ctx.HTML(http.StatusOK, tplUploadFile)
371424
}
372425

373426
func UploadFilePost(ctx *context.Context) {
374-
parsed := parseEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
427+
parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
375428
if ctx.Written() {
376429
return
377430
}
378431

379432
defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/"))
380433
err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
381434
LastCommitID: parsed.form.LastCommit,
382-
OldBranch: ctx.Repo.BranchName,
383-
NewBranch: parsed.TargetBranchName,
435+
OldBranch: parsed.OldBranchName,
436+
NewBranch: parsed.NewBranchName,
384437
TreePath: parsed.form.TreePath,
385438
Message: parsed.GetCommitMessage(defaultCommitMessage),
386439
Files: parsed.form.Files,
@@ -389,7 +442,7 @@ func UploadFilePost(ctx *context.Context) {
389442
Committer: parsed.GitCommitter,
390443
})
391444
if err != nil {
392-
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
445+
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
393446
return
394447
}
395448
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

routers/web/repo/editor_apply_patch.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ func NewDiffPatch(ctx *context.Context) {
2525

2626
// NewDiffPatchPost response for sending patch page
2727
func NewDiffPatchPost(ctx *context.Context) {
28-
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
28+
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
2929
if ctx.Written() {
3030
return
3131
}
3232

3333
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
3434
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
3535
LastCommitID: parsed.form.LastCommit,
36-
OldBranch: ctx.Repo.BranchName,
37-
NewBranch: parsed.TargetBranchName,
36+
OldBranch: parsed.OldBranchName,
37+
NewBranch: parsed.NewBranchName,
3838
Message: parsed.GetCommitMessage(defaultCommitMessage),
3939
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
4040
Author: parsed.GitCommitter,
@@ -44,7 +44,7 @@ func NewDiffPatchPost(ctx *context.Context) {
4444
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
4545
}
4646
if err != nil {
47-
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
47+
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
4848
return
4949
}
5050
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

0 commit comments

Comments
 (0)