diff options
author | zeripath <art27@cantab.net> | 2022-02-09 20:28:55 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-02-09 20:28:55 +0000 |
commit | eb748f5f3c93e8e347309fc75ea8273c06a5489b (patch) | |
tree | fceec474a21fa35437bcf3e90bd549c11976b72e | |
parent | 439ad34c71b8777a9dac369c643560b18bc41bab (diff) | |
download | gitea-eb748f5f3c93e8e347309fc75ea8273c06a5489b.tar.gz gitea-eb748f5f3c93e8e347309fc75ea8273c06a5489b.zip |
Add apply-patch, basic revert and cherry-pick functionality (#17902)
This code adds a simple endpoint to apply patches to repositories and
branches on gitea. This is then used along with the conflicting checking
code in #18004 to provide a basic implementation of cherry-pick revert.
Now because the buttons necessary for cherry-pick and revert have
required us to create a dropdown next to the Browse Source button
I've also implemented Create Branch and Create Tag operations.
Fix #3880
Fix #17986
Signed-off-by: Andrew Thornton <art27@cantab.net>
-rw-r--r-- | modules/git/diff.go | 18 | ||||
-rw-r--r-- | modules/structs/repo_file.go | 8 | ||||
-rw-r--r-- | options/locale/locale_en-US.ini | 19 | ||||
-rw-r--r-- | routers/api/v1/api.go | 1 | ||||
-rw-r--r-- | routers/api/v1/repo/patch.go | 107 | ||||
-rw-r--r-- | routers/web/repo/cherry_pick.go | 189 | ||||
-rw-r--r-- | routers/web/repo/patch.go | 120 | ||||
-rw-r--r-- | routers/web/web.go | 5 | ||||
-rw-r--r-- | services/forms/repo_form.go | 24 | ||||
-rw-r--r-- | services/pull/patch.go | 37 | ||||
-rw-r--r-- | services/repository/files/cherry_pick.go | 126 | ||||
-rw-r--r-- | services/repository/files/patch.go | 193 | ||||
-rw-r--r-- | templates/repo/branch_dropdown.tmpl | 50 | ||||
-rw-r--r-- | templates/repo/commit_page.tmpl | 123 | ||||
-rw-r--r-- | templates/repo/editor/cherry_pick.tmpl | 32 | ||||
-rw-r--r-- | templates/repo/editor/patch.tmpl | 59 | ||||
-rw-r--r-- | templates/repo/home.tmpl | 5 | ||||
-rw-r--r-- | templates/swagger/v1_json.tmpl | 44 | ||||
-rw-r--r-- | web_src/js/components/RepoBranchTagDropdown.js | 60 | ||||
-rw-r--r-- | web_src/js/features/common-global.js | 23 | ||||
-rw-r--r-- | web_src/js/features/repo-branch.js | 17 | ||||
-rw-r--r-- | web_src/js/features/repo-legacy.js | 2 | ||||
-rw-r--r-- | web_src/less/_repository.less | 6 |
23 files changed, 1211 insertions, 57 deletions
diff --git a/modules/git/diff.go b/modules/git/diff.go index d71b0b2471..2d85db4753 100644 --- a/modules/git/diff.go +++ b/modules/git/diff.go @@ -32,6 +32,21 @@ func GetRawDiff(ctx context.Context, repoPath, commitID string, diffType RawDiff return GetRawDiffForFile(ctx, repoPath, "", commitID, diffType, "", writer) } +// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer. +func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error { + stderr := new(bytes.Buffer) + cmd := NewCommand(ctx, "show", "--pretty=format:revert %H%n", "-R", commitID) + if err := cmd.RunWithContext(&RunContext{ + Timeout: -1, + Dir: repoPath, + Stdout: writer, + Stderr: stderr, + }); err != nil { + return fmt.Errorf("Run: %v - %s", err, stderr) + } + return nil +} + // GetRawDiffForFile dumps diff results of file in given commit ID to io.Writer. func GetRawDiffForFile(ctx context.Context, repoPath, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error { repo, closer, err := RepositoryFromContextOrOpen(ctx, repoPath) @@ -221,8 +236,7 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi } } } - err := scanner.Err() - if err != nil { + if err := scanner.Err(); err != nil { return "", err } diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 71733c90e7..e2947bf7ac 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -50,6 +50,14 @@ type UpdateFileOptions struct { FromPath string `json:"from_path" binding:"MaxSize(500)"` } +// ApplyDiffPatchFileOptions options for applying a diff patch +// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +type ApplyDiffPatchFileOptions struct { + DeleteFileOptions + // required: true + Content string `json:"content"` +} + // FileLinksResponse contains the links for a repo's file type FileLinksResponse struct { Self *string `json:"self"` diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e91016bdc0..7b3671f90a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1075,6 +1075,10 @@ editor.add_tmpl = Add '<filename>' editor.add = Add '%s' editor.update = Update '%s' editor.delete = Delete '%s' +editor.patch = Apply Patch +editor.patching = Patching: +editor.fail_to_apply_patch = Unable to apply patch '%s' +editor.new_patch = New Patch editor.commit_message_desc = Add an optional extended description… editor.signoff_desc = Add a Signed-off-by trailer by the committer at the end of the commit log message. editor.commit_directly_to_this_branch = Commit directly to the <strong class="branch-name">%s</strong> branch. @@ -1110,6 +1114,8 @@ editor.cannot_commit_to_protected_branch = Cannot commit to protected branch '%s editor.no_commit_to_branch = Unable to commit directly to branch because: editor.user_no_push_to_branch = User cannot push to branch editor.require_signed_commit = Branch requires a signed commit +editor.cherry_pick = Cherry-pick %s onto: +editor.revert = Revert %s onto: commits.desc = Browse source code change history. commits.commits = Commits @@ -1130,6 +1136,14 @@ commits.signed_by_untrusted_user_unmatched = Signed by untrusted user who does n commits.gpg_key_id = GPG Key ID commits.ssh_key_fingerprint = SSH Key Fingerprint +commit.actions = Actions +commit.revert = Revert +commit.revert-header = Revert: %s +commit.revert-content = Select branch to revert onto: +commit.cherry-pick = Cherry-pick +commit.cherry-pick-header = Cherry-pick: %s +commit.cherry-pick-content = Select branch to cherry-pick onto: + ext_issues = Access to External Issues ext_issues.desc = Link to an external issue tracker. @@ -2215,11 +2229,16 @@ branch.included_desc = This branch is part of the default branch branch.included = Included branch.create_new_branch = Create branch from branch: branch.confirm_create_branch = Create branch +branch.create_branch_operation = Create branch branch.new_branch = Create new branch branch.new_branch_from = Create new branch from '%s' branch.renamed = Branch %s was renamed to %s. tag.create_tag = Create tag <strong>%s</strong> +tag.create_tag_operation = Create tag +tag.confirm_create_tag = Create tag +tag.create_tag_from = Create new tag from '%s' + tag.create_success = Tag '%s' has been created. topic.manage_topics = Manage Topics diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index df00a852f2..6d8ab8ce98 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -975,6 +975,7 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { m.Get("/tags/{sha}", context.RepoRefForAPI, repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) }, reqRepoReader(unit.TypeCode)) + m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) m.Get("/*", repo.GetContents) diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go new file mode 100644 index 0000000000..64a7a32d16 --- /dev/null +++ b/routers/api/v1/repo/patch.go @@ -0,0 +1,107 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/repository/files" +) + +// ApplyDiffPatch handles API call for applying a patch +func ApplyDiffPatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/diffpatch repository repoApplyDiffPatch + // --- + // summary: Apply diff patch to repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/UpdateFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileResponse" + apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions) + + opts := &files.ApplyDiffPatchOptions{ + Content: apiOpts.Content, + SHA: apiOpts.SHA, + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &files.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: apiOpts.Author.Email, + }, + Dates: &files.CommitDateOptions{ + Author: apiOpts.Dates.Author, + Committer: apiOpts.Dates.Committer, + }, + Signoff: apiOpts.Signoff, + } + if opts.Dates.Author.IsZero() { + opts.Dates.Author = time.Now() + } + if opts.Dates.Committer.IsZero() { + opts.Dates.Committer = time.Now() + } + + if opts.Message == "" { + opts.Message = "apply-patch" + } + + if !canWriteFiles(ctx.Repo) { + ctx.Error(http.StatusInternalServerError, "ApplyPatch", models.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.User.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + return + } + + fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.User, opts) + if err != nil { + if models.IsErrUserCannotCommit(err) || models.IsErrFilePathProtected(err) { + ctx.Error(http.StatusForbidden, "Access", err) + return + } + if models.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) || + models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) { + ctx.Error(http.StatusUnprocessableEntity, "Invalid", err) + return + } + if models.IsErrBranchDoesNotExist(err) || git.IsErrBranchNotExist(err) { + ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err) + return + } + ctx.Error(http.StatusInternalServerError, "ApplyPatch", err) + } else { + ctx.JSON(http.StatusCreated, fileResponse) + } +} diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go new file mode 100644 index 0000000000..1b0d8452e2 --- /dev/null +++ b/routers/web/repo/cherry_pick.go @@ -0,0 +1,189 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "bytes" + "errors" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +var tplCherryPick base.TplName = "repo/editor/cherry_pick" + +// CherryPick handles cherrypick GETs +func CherryPick(ctx *context.Context) { + ctx.Data["SHA"] = ctx.Params(":sha") + cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(ctx.Params(":sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Missing Commit", err) + return + } + ctx.ServerError("GetCommit", err) + return + } + + if ctx.FormString("cherry-pick-type") == "revert" { + ctx.Data["CherryPickType"] = "revert" + ctx.Data["commit_summary"] = "revert " + ctx.Params(":sha") + ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) + ctx.Data["commit_summary"] = splits[0] + ctx.Data["commit_message"] = splits[1] + } + + ctx.Data["RequireHighlightJS"] = true + + canCommit := renderCommitRights(ctx) + ctx.Data["TreePath"] = "patch" + + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplCherryPick) +} + +// CherryPickPost handles cherrypick POSTs +func CherryPickPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CherryPickForm) + + sha := ctx.Params(":sha") + ctx.Data["SHA"] = sha + if form.Revert { + ctx.Data["CherryPickType"] = "revert" + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + } + + ctx.Data["RequireHighlightJS"] = true + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + if ctx.HasError() { + ctx.HTML(200, tplCherryPick) + return + } + + // Cannot commit to a 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 + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplCherryPick, &form) + return + } + + message := strings.TrimSpace(form.CommitSummary) + if message == "" { + if form.Revert { + message = ctx.Tr("repo.commit.revert-header", sha) + } else { + message = ctx.Tr("repo.commit.cherry-pick-header", sha) + } + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + opts := &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + } + + // First lets try the simple plain read-tree -m approach + opts.Content = sha + if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.User, form.Revert, opts); err != nil { + if models.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(models.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + // Drop through to the apply technique + + buf := &bytes.Buffer{} + if form.Revert { + if err := git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } else { + if err := git.GetRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, git.RawDiffType("patch"), buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } + + opts.Content = buf.String() + ctx.Data["FileContent"] = opts.Content + + if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.User, opts); err != nil { + if models.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(models.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } else { + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + } + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(unit.TypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName)) + } +} diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go new file mode 100644 index 0000000000..b00065a6ee --- /dev/null +++ b/routers/web/repo/patch.go @@ -0,0 +1,120 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +const ( + tplPatchFile base.TplName = "repo/editor/patch" +) + +// NewDiffPatch render create patch page +func NewDiffPatch(ctx *context.Context) { + ctx.Data["RequireHighlightJS"] = true + + canCommit := renderCommitRights(ctx) + + ctx.Data["TreePath"] = "patch" + + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplPatchFile) +} + +// NewDiffPatchPost response for sending patch page +func NewDiffPatchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + ctx.Data["RequireHighlightJS"] = true + ctx.Data["TreePath"] = "patch" + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["FileContent"] = form.Content + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + + if ctx.HasError() { + ctx.HTML(200, tplPatchFile) + return + } + + // Cannot commit to a 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 + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + return + } + + // CommitSummary is optional in the web form, if empty, give it a default message based on add or update + // `message` will be both the summary and message combined + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + message = ctx.Tr("repo.editor.patch") + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.User, &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Content: strings.ReplaceAll(form.Content, "\r", ""), + }); err != nil { + if models.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(models.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } else { + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(unit.TypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(form.TreePath)) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index 60a379aef8..52eca9a0a6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -808,6 +808,10 @@ func RegisterRoutes(m *web.Route) { m.Combo("/_upload/*", repo.MustBeAbleToUpload). Get(repo.UploadFile). Post(bindIgnErr(forms.UploadRepoFileForm{}), repo.UploadFilePost) + m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). + Post(bindIgnErr(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) + m.Combo("/_cherrypick/{sha:([a-f0-9]{7,40})}/*").Get(repo.CherryPick). + Post(bindIgnErr(forms.CherryPickForm{}), repo.CherryPickPost) }, context.RepoRefByType(context.RepoRefBranch), repo.MustBeEditable) m.Group("", func() { m.Post("/upload-file", repo.UploadFileToServer) @@ -1029,6 +1033,7 @@ func RegisterRoutes(m *web.Route) { m.Group("", func() { m.Get("/graph", repo.Graph) m.Get("/commit/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/cherry-pick/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.CherryPick) }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) m.Group("/src", func() { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index b32bd3cafd..da709ef800 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -754,6 +754,30 @@ func (f *EditPreviewDiffForm) Validate(req *http.Request, errs binding.Errors) b return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// _________ .__ __________.__ __ +// \_ ___ \| |__ __________________ ___.__. \______ \__| ____ | | __ +// / \ \/| | \_/ __ \_ __ \_ __ < | | | ___/ |/ ___\| |/ / +// \ \___| Y \ ___/| | \/| | \/\___ | | | | \ \___| < +// \______ /___| /\___ >__| |__| / ____| |____| |__|\___ >__|_ \ +// \/ \/ \/ \/ \/ \/ + +// CherryPickForm form for changing repository file +type CherryPickForm struct { + CommitSummary string `binding:"MaxSize(100)"` + CommitMessage string + CommitChoice string `binding:"Required;MaxSize(50)"` + NewBranchName string `binding:"GitRefName;MaxSize(100)"` + LastCommit string + Revert bool + Signoff bool +} + +// Validate validates the fields +func (f *CherryPickForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // ____ ___ .__ .___ // | | \______ | | _________ __| _/ // | | /\____ \| | / _ \__ \ / __ | diff --git a/services/pull/patch.go b/services/pull/patch.go index 731c9d5717..a2c8345326 100644 --- a/services/pull/patch.go +++ b/services/pull/patch.go @@ -87,7 +87,7 @@ func TestPatch(pr *models.PullRequest) error { pr.MergeBase = strings.TrimSpace(pr.MergeBase) // 2. Check for conflicts - if conflicts, err := checkConflicts(pr, gitRepo, tmpBasePath); err != nil || conflicts || pr.Status == models.PullRequestStatusEmpty { + if conflicts, err := checkConflicts(ctx, pr, gitRepo, tmpBasePath); err != nil || conflicts || pr.Status == models.PullRequestStatusEmpty { return err } @@ -217,19 +217,20 @@ func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, g return nil } -func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) { - ctx, cancel, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("checkConflicts: pr[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index)) - defer finished() +// AttemptThreeWayMerge will attempt to three way merge using git read-tree and then follow the git merge-one-file algorithm to attempt to resolve basic conflicts +func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repository, base, ours, theirs, description string) (bool, []string, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() // First we use read-tree to do a simple three-way merge - if _, err := git.NewCommand(ctx, "read-tree", "-m", pr.MergeBase, "base", "tracking").RunInDir(tmpBasePath); err != nil { + if _, err := git.NewCommand(ctx, "read-tree", "-m", base, ours, theirs).RunInDir(gitPath); err != nil { log.Error("Unable to run read-tree -m! Error: %v", err) - return false, fmt.Errorf("unable to run read-tree -m! Error: %v", err) + return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %v", err) } // Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles unmerged := make(chan *unmergedFile) - go unmergedFiles(ctx, tmpBasePath, unmerged) + go unmergedFiles(ctx, gitPath, unmerged) defer func() { cancel() @@ -239,8 +240,8 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath }() numberOfConflicts := 0 - pr.ConflictedFiles = make([]string, 0, 5) conflict := false + conflictedFiles := make([]string, 0, 5) for file := range unmerged { if file == nil { @@ -248,23 +249,33 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath } if file.err != nil { cancel() - return false, file.err + return false, nil, file.err } // OK now we have the unmerged file triplet attempt to merge it - if err := attemptMerge(ctx, file, tmpBasePath, gitRepo); err != nil { + if err := attemptMerge(ctx, file, gitPath, gitRepo); err != nil { if conflictErr, ok := err.(*errMergeConflict); ok { - log.Trace("Conflict: %s in PR[%d] %s/%s#%d", conflictErr.filename, pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index) + log.Trace("Conflict: %s in %s", conflictErr.filename, description) conflict = true if numberOfConflicts < 10 { - pr.ConflictedFiles = append(pr.ConflictedFiles, conflictErr.filename) + conflictedFiles = append(conflictedFiles, conflictErr.filename) } numberOfConflicts++ continue } - return false, err + return false, nil, err } } + return conflict, conflictedFiles, nil +} + +func checkConflicts(ctx context.Context, pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) { + description := fmt.Sprintf("PR[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index) + conflict, _, err := AttemptThreeWayMerge(ctx, + tmpBasePath, gitRepo, pr.MergeBase, "base", "tracking", description) + if err != nil { + return false, err + } if !conflict { treeHash, err := git.NewCommand(ctx, "write-tree").RunInDir(tmpBasePath) diff --git a/services/repository/files/cherry_pick.go b/services/repository/files/cherry_pick.go new file mode 100644 index 0000000000..dc932b39c2 --- /dev/null +++ b/services/repository/files/cherry_pick.go @@ -0,0 +1,126 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package files + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/pull" +) + +// CherryPick cherrypicks or reverts a commit to the given repository +func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) { + if err := opts.Validate(ctx, repo, doer); err != nil { + return nil, err + } + message := strings.TrimSpace(opts.Message) + + author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer) + + t, err := NewTemporaryUploadRepository(ctx, repo) + if err != nil { + log.Error("%v", err) + } + defer t.Close() + if err := t.Clone(opts.OldBranch); err != nil { + return nil, err + } + if err := t.SetDefaultIndex(); err != nil { + return nil, err + } + + // Get the commit of the original branch + commit, err := t.GetBranchCommit(opts.OldBranch) + if err != nil { + return nil, err // Couldn't get a commit for the branch + } + + // Assigned LastCommitID in opts if it hasn't been set + if opts.LastCommitID == "" { + opts.LastCommitID = commit.ID.String() + } else { + lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + if err != nil { + return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %v", err) + } + opts.LastCommitID = lastCommitID.String() + if commit.ID.String() != opts.LastCommitID { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + } + + commit, err = t.GetCommit(strings.TrimSpace(opts.Content)) + if err != nil { + return nil, err + } + parent, err := commit.ParentID(0) + if err != nil { + parent = git.MustIDFromString(git.EmptyTreeSHA) + } + + base, right := parent.String(), commit.ID.String() + + if revert { + right, base = base, right + } + + description := fmt.Sprintf("CherryPick %s onto %s", right, opts.OldBranch) + conflict, _, err := pull.AttemptThreeWayMerge(ctx, + t.basePath, t.gitRepo, base, opts.LastCommitID, right, description) + if err != nil { + return nil, fmt.Errorf("failed to three-way merge %s onto %s: %v", right, opts.OldBranch, err) + } + + if conflict { + return nil, fmt.Errorf("failed to merge due to conflicts") + } + + treeHash, err := t.WriteTree() + if err != nil { + // likely non-sensical tree due to merge conflicts... + return nil, err + } + + // Now commit the tree + var commitHash string + if opts.Dates != nil { + commitHash, err = t.CommitTreeWithDate(author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer) + } else { + commitHash, err = t.CommitTree(author, committer, treeHash, message, opts.Signoff) + } + if err != nil { + return nil, err + } + + // Then push this tree to NewBranch + if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + return nil, err + } + + commit, err = t.GetCommit(commitHash) + if err != nil { + return nil, err + } + + fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil + verification := GetPayloadCommitVerification(commit) + fileResponse := &structs.FileResponse{ + Commit: fileCommitResponse, + Verification: verification, + } + + return fileResponse, nil +} diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go new file mode 100644 index 0000000000..09a8b3ea0c --- /dev/null +++ b/services/repository/files/patch.go @@ -0,0 +1,193 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package files + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/structs" + asymkey_service "code.gitea.io/gitea/services/asymkey" +) + +// ApplyDiffPatchOptions holds the repository diff patch update options +type ApplyDiffPatchOptions struct { + LastCommitID string + OldBranch string + NewBranch string + Message string + Content string + SHA string + Author *IdentityOptions + Committer *IdentityOptions + Dates *CommitDateOptions + Signoff bool +} + +// Validate validates the provided options +func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_model.Repository, doer *user_model.User) error { + // If no branch name is set, assume master + if opts.OldBranch == "" { + opts.OldBranch = repo.DefaultBranch + } + if opts.NewBranch == "" { + opts.NewBranch = opts.OldBranch + } + + gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + if err != nil { + return err + } + defer closer.Close() + + // oldBranch must exist for this operation + if _, err := gitRepo.GetBranch(opts.OldBranch); err != nil { + return err + } + // A NewBranch can be specified for the patch to be applied to. + // Check to make sure the branch does not already exist, otherwise we can't proceed. + // If we aren't branching to a new branch, make sure user can commit to the given branch + if opts.NewBranch != opts.OldBranch { + existingBranch, err := gitRepo.GetBranch(opts.NewBranch) + if existingBranch != nil { + return models.ErrBranchAlreadyExists{ + BranchName: opts.NewBranch, + } + } + if err != nil && !git.IsErrBranchNotExist(err) { + return err + } + } else { + protectedBranch, err := models.GetProtectedBranchBy(repo.ID, opts.OldBranch) + if err != nil { + return err + } + if protectedBranch != nil && !protectedBranch.CanUserPush(doer.ID) { + return models.ErrUserCannotCommit{ + UserName: doer.LowerName, + } + } + if protectedBranch != nil && protectedBranch.RequireSignedCommits { + _, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), opts.OldBranch) + if err != nil { + if !asymkey_service.IsErrWontSign(err) { + return err + } + return models.ErrUserCannotCommit{ + UserName: doer.LowerName, + } + } + } + } + return nil +} + +// ApplyDiffPatch applies a patch to the given repository +func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) { + if err := opts.Validate(ctx, repo, doer); err != nil { + return nil, err + } + + message := strings.TrimSpace(opts.Message) + + author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer) + + t, err := NewTemporaryUploadRepository(ctx, repo) + if err != nil { + log.Error("%v", err) + } + defer t.Close() + if err := t.Clone(opts.OldBranch); err != nil { + return nil, err + } + if err := t.SetDefaultIndex(); err != nil { + return nil, err + } + + // Get the commit of the original branch + commit, err := t.GetBranchCommit(opts.OldBranch) + if err != nil { + return nil, err // Couldn't get a commit for the branch + } + + // Assigned LastCommitID in opts if it hasn't been set + if opts.LastCommitID == "" { + opts.LastCommitID = commit.ID.String() + } else { + lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + if err != nil { + return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %v", err) + } + opts.LastCommitID = lastCommitID.String() + if commit.ID.String() != opts.LastCommitID { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + } + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + + args := []string{"apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary"} + + if git.CheckGitVersionAtLeast("2.32") == nil { + args = append(args, "-3") + } + + cmd := git.NewCommand(ctx, args...) + if err := cmd.RunWithContext(&git.RunContext{ + Timeout: -1, + Dir: t.basePath, + Stdout: stdout, + Stderr: stderr, + Stdin: strings.NewReader(opts.Content), + }); err != nil { + return nil, fmt.Errorf("Error: Stdout: %s\nStderr: %s\nErr: %v", stdout.String(), stderr.String(), err) + } + + // Now write the tree + treeHash, err := t.WriteTree() + if err != nil { + return nil, err + } + + // Now commit the tree + var commitHash string + if opts.Dates != nil { + commitHash, err = t.CommitTreeWithDate(author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer) + } else { + commitHash, err = t.CommitTree(author, committer, treeHash, message, opts.Signoff) + } + if err != nil { + return nil, err + } + + // Then push this tree to NewBranch + if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + return nil, err + } + + commit, err = t.GetCommit(commitHash) + if err != nil { + return nil, err + } + + fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil + verification := GetPayloadCommitVerification(commit) + fileResponse := &structs.FileResponse{ + Commit: fileCommitResponse, + Verification: verification, + } + + return fileResponse, nil +} diff --git a/templates/repo/branch_dropdown.tmpl b/templates/repo/branch_dropdown.tmpl index a6120483b5..0e263e8075 100644 --- a/templates/repo/branch_dropdown.tmpl +++ b/templates/repo/branch_dropdown.tmpl @@ -1,37 +1,51 @@ {{$release := .release}} +{{$defaultBranch := $.root.BranchName}}{{if and .root.IsViewTag (not .noTag)}}{{$defaultBranch = .root.TagName}}{{end}}{{if eq $defaultBranch ""}}{{$defaultBranch = $.root.Repository.DefaultBranch}}{{end}} {{$showBranchesInDropdown := not .root.HideBranchesInDropdown}} <div class="fitted item choose reference{{if not $release}} mr-1{{end}}"> - <div class="ui floating filter dropdown custom" data-can-create-branch="{{.root.CanCreateBranch}}" data-no-results="{{.root.i18n.Tr "repo.pulls.no_results"}}"> + <div class="ui floating filter dropdown custom" + data-branch-form="{{if $.branchForm}}{{$.branchForm}}{{end}}" + data-can-create-branch="{{if .canCreateBranch}}{{.canCreateBranch}}{{else}}{{.root.CanCreateBranch}}{{end}}" + data-no-results="{{.root.i18n.Tr "repo.pulls.no_results"}}" + data-set-action="{{.setAction}}" data-submit-form="{{.submitForm}}" + data-view-type="{{if and .root.IsViewTag (not .noTag)}}tag{{else if .root.IsViewBranch}}branch{{else}}tree{{end}}" + data-ref-name="{{if and .root.IsViewTag (not .noTag)}}{{.root.TagName}}{{else if .root.IsViewBranch}}{{.root.BranchName}}{{else}}{{ShortSha .root.CommitID}}{{end}}" + data-branch-url-prefix="{{if .branchURLPrefix}}{{.branchURLPrefix}}{{else}}{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/branch/{{end}}" + data-branch-url-suffix="{{if .branchURLSuffix}}{{.branchURLSuffix}}{{else}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}{{end}}" + data-tag-url-prefix="{{if .tagURLPrefix}}{{.tagURLPrefix}}{{else if $release}}{{$.root.RepoLink}}/compare/{{else}}{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/tag/{{end}}" + data-tag-url-suffix="{{if .tagURLSuffix}}{{.tagURLSuffix}}{{else if $release}}...{{if $release.IsDraft}}{{PathEscapeSegments $release.Target}}{{else}}{{if $release.TagName}}{{PathEscapeSegments $release.TagName}}{{else}}{{PathEscapeSegments $release.Sha1}}{{end}}{{end}}{{else}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}{{end}}"> <div class="ui basic small compact button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible"> <span class="text"> {{if $release}} {{.root.i18n.Tr "repo.release.compare"}} {{else}} - {{if .root.IsViewTag}}{{svg "octicon-tag"}}{{else}}{{svg "octicon-git-branch"}}{{end}} - {{if .root.IsViewBranch}}{{.root.i18n.Tr "repo.branch"}}{{else if .root.IsViewTag}}{{.root.i18n.Tr "repo.tag"}}{{else}}{{.root.i18n.Tr "repo.tree"}}{{end}}: - <strong>{{if .root.IsViewBranch}}{{.root.BranchName}}{{else if .root.IsViewTag}}{{.root.TagName}}{{else}}{{ShortSha .root.CommitID}}{{end}}</strong> + <span :class="{visible: isViewTag}" v-if="isViewTag" v-cloak>{{svg "octicon-tag"}} {{.root.i18n.Tr "repo.tag"}}:</span> + <span :class="{visible: isViewBranch}" v-if="isViewBranch" v-cloak>{{svg "octicon-git-branch"}} {{.root.i18n.Tr "repo.branch"}}:</span> + <span :class="{visible: isViewTree}" v-if="isViewTree" v-cloak>{{svg "octicon-git-branch"}} {{.root.i18n.Tr "repo.tree"}}:</span> + <strong ref="dropdownRefName">{{if and .root.IsViewTag (not .noTag)}}{{.root.TagName}}{{else if .root.IsViewBranch}}{{.root.BranchName}}{{else}}{{ShortSha .root.CommitID}}{{end}}</strong> {{end}} </span> {{svg "octicon-triangle-down" 14 "dropdown icon"}} </div> - <div class="data" style="display: none" data-mode="{{if .root.IsViewTag}}tags{{else}}branches{{end}}"> + <div class="data" style="display: none" data-mode="{{if or .root.IsViewTag .isTag}}tags{{else}}branches{{end}}"> {{if $showBranchesInDropdown}} {{range .root.Branches}} - <div class="item branch {{if eq $.root.BranchName .}}selected{{end}}" data-url="{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/branch/{{PathEscapeSegments .}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}">{{.}}</div> + <div class="item branch {{if eq $defaultBranch .}}selected{{end}}" data-url="{{PathEscapeSegments .}}">{{.}}</div> {{end}} {{end}} - {{range .root.Tags}} - {{if $release}} - <div class="item tag {{if eq $release.TagName .}}selected{{end}}" data-url="{{$.root.RepoLink}}/compare/{{PathEscapeSegments .}}...{{if $release.IsDraft}}{{PathEscapeSegments $release.Target}}{{else}}{{if $release.TagName}}{{PathEscapeSegments $release.TagName}}{{else}}{{PathEscapeSegments $release.Sha1}}{{end}}{{end}}">{{.}}</div> - {{else}} - <div class="item tag {{if eq $.root.BranchName .}}selected{{end}}" data-url="{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/tag/{{PathEscapeSegments .}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}">{{.}}</div> + {{if (not .noTag)}} + {{range .root.Tags}} + {{if $release}} + <div class="item tag {{if eq $release.TagName .}}selected{{end}}" data-url="{{PathEscapeSegments .}}">{{.}}</div> + {{else}} + <div class="item tag {{if eq $defaultBranch .}}selected{{end}}" data-url="{{PathEscapeSegments .}}">{{.}}</div> + {{end}} {{end}} {{end}} </div> <div class="menu transition" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak> <div class="ui icon search input"> <i class="icon df ac jc m-0">{{svg "octicon-filter" 16}}</i> - <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" placeholder="{{if $showBranchesInDropdown}}{{.root.i18n.Tr "repo.filter_branch_and_tag"}}{{else}}{{.root.i18n.Tr "repo.find_tag"}}{{end}}..."> + <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" placeholder="{{if $.noTag}}{{.root.i18n.Tr "repo.filter_branch"}}{{else if $showBranchesInDropdown}}{{.root.i18n.Tr "repo.filter_branch_and_tag"}}{{else}}{{.root.i18n.Tr "repo.find_tag"}}{{end}}..."> </div> {{if $showBranchesInDropdown}} <div class="header branch-tag-choice"> @@ -42,11 +56,13 @@ {{svg "octicon-git-branch" 16 "mr-2"}}{{.root.i18n.Tr "repo.branches"}} </span> </a> - <a class="reference column" href="#" @click="createTag = true; mode = 'tags'; focusSearchField()"> - <span class="text" :class="{black: mode == 'tags'}"> - {{svg "octicon-tag" 16 "mr-2"}}{{.root.i18n.Tr "repo.tags"}} - </span> - </a> + {{if not .noTag}} + <a class="reference column" href="#" @click="createTag = true; mode = 'tags'; focusSearchField()"> + <span class="text" :class="{black: mode == 'tags'}"> + {{svg "octicon-tag" 16 "mr-2"}}{{.root.i18n.Tr "repo.tags"}} + </span> + </a> + {{end}} </div> </div> </div> diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 331c439c02..370cafa2e1 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -18,14 +18,123 @@ {{end}} {{end}} <div class="ui top attached header clearing segment pr {{$class}}"> - {{if not $.PageIsWiki}} - <a class="ui blue tiny button browse-button" href="{{.SourcePath}}"> - {{.i18n.Tr "repo.diff.browse_source"}} - </a> - {{end}} - <h3 class="mt-0"><span class="message-wrapper"><span class="commit-summary" title="{{.Commit.Summary}}">{{RenderCommitMessage $.Context .Commit.Message $.RepoLink $.Repository.ComposeMetas}}</span></span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses "root" $}}</h3> + <div class="df mb-4"> + <h3 class="mb-0 f1"><span class="commit-summary" title="{{.Commit.Summary}}">{{RenderCommitMessage $.Context .Commit.Message $.RepoLink $.Repository.ComposeMetas}}</span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses "root" $}}</h3> + {{if not $.PageIsWiki}} + <div class="ui"> + <a class="ui blue tiny button" href="{{.SourcePath}}"> + {{.i18n.Tr "repo.diff.browse_source"}} + </a> + {{if and ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}}{{- /* */ -}} + <div class="ui blue tiny floating dropdown icon button">{{.i18n.Tr "repo.commit.actions"}} + {{svg "octicon-triangle-down" 14 "dropdown icon"}}<span class="sr-mobile-only">{{.i18n.Tr "repo.commit.actions"}}</span> + <div class="menu"> + <div class="ui header">{{.i18n.Tr "repo.commit.actions"}}</div> + <div class="divider"></div> + <div class="item show-create-branch-modal" + data-content="{{$.i18n.Tr "repo.branch.new_branch_from" (.CommitID)}}" + data-branch-from="{{ShortSha .CommitID}}" + data-branch-from-urlcomponent="{{.CommitID}}" + data-modal="#create-branch-modal"> + {{.i18n.Tr "repo.branch.create_branch_operation"}} + </div> + <div class="item show-create-branch-modal" + data-content="{{$.i18n.Tr "repo.branch.new_branch_from" (.CommitID)}}" + data-branch-from="{{ShortSha .CommitID}}" + data-branch-from-urlcomponent="{{.CommitID}}" + data-modal="#create-tag-modal" + data-modal-from-span="#modal-create-tag-from-span" + data-modal-form="#create-tag-form"> + {{.i18n.Tr "repo.tag.create_tag_operation"}} + </div> + <div class="item show-modal revert-button" + data-modal="#cherry-pick-modal" + data-modal-cherry-pick-type="revert" + data-modal-cherry-pick-header="{{$.i18n.Tr "repo.commit.revert-header" (ShortSha .CommitID)}}" + data-modal-cherry-pick-content="{{$.i18n.Tr "repo.commit.revert-content"}}" + data-modal-cherry-pick-submit="{{.i18n.Tr "repo.commit.revert"}}">{{.i18n.Tr "repo.commit.revert"}}</a></div> + <div class="item cherry-pick-button show-modal" + data-modal="#cherry-pick-modal" + data-modal-cherry-pick-type="cherry-pick" + data-modal-cherry-pick-header="{{$.i18n.Tr "repo.commit.cherry-pick-header" (ShortSha .CommitID)}}" + data-modal-cherry-pick-content="{{$.i18n.Tr "repo.commit.cherry-pick-content"}}" + data-modal-cherry-pick-submit="{{.i18n.Tr "repo.commit.cherry-pick"}}">{{.i18n.Tr "repo.commit.cherry-pick"}}</a></div> + <div class="ui basic modal" id="cherry-pick-modal"> + <div class="ui icon header"> + <span id="cherry-pick-header"></span> + </div> + <div class="content center"> + <p id="cherry-pick-content" class="branch-dropdown"></p> + {{template "repo/branch_dropdown" dict "root" . + "noTag" "true" "canCreateBranch" "false" + "branchForm" "branch-dropdown-form" + "branchURLPrefix" (printf "%s/_cherrypick/%s/" $.RepoLink .CommitID) "branchURLSuffix" "" + "setAction" "true" "submitForm" "true"}} + <form method="GET" action="{{$.RepoLink}}/_cherrypick/{{.CommitID}}/{{if $.BranchName}}{{PathEscapeSegments $.BranchName}}{{else}}{{PathEscapeSegments $.Repository.DefaultBranch}}{{end}}" id="branch-dropdown-form"> + <input type="hidden" name="ref" value="{{if $.BranchName}}{{$.BranchName}}{{else}}{{$.Repository.DefaultBranch}}{{end}}"> + <input type="hidden" name="refType" value="branch"> + <input type="hidden" id="cherry-pick-type" name="cherry-pick-type"><br/> + <button type="submit" id="cherry-pick-submit" class="ui green button"></button> + </form> + </div> + </div> + <div class="ui small modal" id="create-branch-modal"> + <div class="header"> + {{.i18n.Tr "repo.branch.new_branch"}} + </div> + <div class="content"> + <form class="ui form" id="create-branch-form" action="" data-base-action="{{.RepoLink}}/branches/_new/commit/" method="post"> + {{.CsrfTokenHtml}} + <div class="field"> + <label> + {{.i18n.Tr "repo.branch.new_branch_from" "<span class=\"text\" id=\"modal-create-branch-from-span\"></span>" | Safe }} + </label> + </div> + <div class="required field"> + <label for="new_branch_name">{{.i18n.Tr "repo.branch.name"}}</label> + <input id="new_branch_name" name="new_branch_name" required> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> + <button class="ui green button">{{.i18n.Tr "repo.branch.confirm_create_branch"}}</button> + </div> + </form> + </div> + </div> + <div class="ui small modal" id="create-tag-modal"> + <div class="header"> + {{.i18n.Tr "repo.tag.create_tag_operation"}} + </div> + <div class="content"> + <form class="ui form" id="create-tag-form" action="" data-base-action="{{.RepoLink}}/branches/_new/commit/" method="post"> + {{.CsrfTokenHtml}} + <input type="hidden" name="create_tag" value="true"> + <div class="field"> + <label> + {{.i18n.Tr "repo.tag.create_tag_from" "<span class=\"text\" id=\"modal-create-tag-from-span\"></span>" | Safe }} + </label> + </div> + <div class="required field"> + <label for="new_branch_name">{{.i18n.Tr "repo.release.tag_name"}}</label> + <input id="new_branch_name" name="new_branch_name" required> + </div> + + <div class="text right actions"> + <div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> + <button class="ui green button">{{.i18n.Tr "repo.tag.confirm_create_tag"}}</button> + </div> + </form> + </div> + </div> + </div> + </div> + {{end}} + </div> + {{end}} + </div> {{if IsMultilineCommitMessage .Commit.Message}} - <pre class="commit-body">{{RenderCommitBody $.Context .Commit.Message $.RepoLink $.Repository.ComposeMetas}}</pre> + <pre class="commit-body mt-0">{{RenderCommitBody $.Context .Commit.Message $.RepoLink $.Repository.ComposeMetas}}</pre> {{end}} {{if .BranchName}} <span class="text grey mr-3">{{svg "octicon-git-branch" 16 "mr-2"}}{{.BranchName}}</span> diff --git a/templates/repo/editor/cherry_pick.tmpl b/templates/repo/editor/cherry_pick.tmpl new file mode 100644 index 0000000000..9cdda2ea83 --- /dev/null +++ b/templates/repo/editor/cherry_pick.tmpl @@ -0,0 +1,32 @@ +{{template "base/head" .}} +<div class="page-content repository file editor edit"> + {{template "repo/header" .}} + <div class="ui container"> + {{template "base/alert" .}} + <form class="ui edit form" method="post" action="{{.RepoLink}}/_cherrypick/{{.SHA}}/{{.BranchName | PathEscapeSegments}}"> + {{.CsrfTokenHtml}} + <input type="hidden" name="last_commit" value="{{.last_commit}}"> + <input type="hidden" name="page_has_posted" value="true"> + <input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}"> + <div class="ui secondary menu"> + <div class="fitted item treepath"> + <div class="ui breadcrumb field {{if .Err_TreePath}}error{{end}}"> + {{$shaurl := printf "%s/commit/%s" $.RepoLink (PathEscape .SHA)}} + {{$shalink := printf "<a class=\"ui blue sha label\" href=\"%s\">%s</a>" (Escape $shaurl) (ShortSha .SHA)}} + {{if eq .CherryPickType "revert"}} + {{.i18n.Tr "repo.editor.revert" $shalink | Str2html}} + {{else}} + {{.i18n.Tr "repo.editor.cherry_pick" $shalink | Str2html}} + {{end}} + <a class="section" href="{{$.RepoLink}}">{{.Repository.FullName}}</a> + <div class="divider">:</div> + <a class="section" href="{{$.BranchLink}}">{{.BranchName}}</a> + <span>{{.i18n.Tr "repo.editor.or"}} <a href="{{$shaurl}}">{{.i18n.Tr "repo.editor.cancel_lower"}}</a></span> + </div> + </div> + </div> + {{template "repo/editor/commit_form" .}} + </form> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl new file mode 100644 index 0000000000..cecda907a4 --- /dev/null +++ b/templates/repo/editor/patch.tmpl @@ -0,0 +1,59 @@ +{{template "base/head" .}} +<div class="page-content repository file editor edit"> + {{template "repo/header" .}} + <div class="ui container"> + {{template "base/alert" .}} + <form class="ui edit form" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}"> + {{.CsrfTokenHtml}} + <input type="hidden" name="last_commit" value="{{.last_commit}}"> + <input type="hidden" name="page_has_posted" value="{{.PageHasPosted}}"> + <div class="ui secondary menu"> + <div class="fitted item treepath"> + <div class="ui breadcrumb field {{if .Err_TreePath}}error{{end}}"> + {{.i18n.Tr "repo.editor.patching"}} + <a class="section" href="{{$.RepoLink}}">{{.Repository.FullName}}</a> + <div class="divider">:</div> + <a class="section" href="{{$.BranchLink}}">{{.BranchName}}</a> + <span>{{.i18n.Tr "repo.editor.or"}} <a href="{{$.BranchLink}}">{{.i18n.Tr "repo.editor.cancel_lower"}}</a></span> + <input type="hidden" id="tree_path" name="tree_path" value="patch" required> + <input id="file-name" type="hidden" value="diff.patch"> + </div> + </div> + </div> + <div class="field"> + <div class="ui top attached tabular menu" data-write="write"> + <a class="active item" data-tab="write">{{svg "octicon-code" 16 "mr-2"}}{{.i18n.Tr "repo.editor.new_patch"}}</a> + </div> + <div class="ui bottom attached active tab segment" data-tab="write"> + <textarea id="edit_area" name="content" class="hide" data-id="repo-{{.Repository.Name}}-patch" + data-context="{{.RepoLink}}" + data-line-wrap-extensions="{{.LineWrapExtensions}}"> +{{.FileContent}}</textarea> + <div class="editor-loading is-loading"></div> + </div> + </div> + {{template "repo/editor/commit_form" .}} + </form> + </div> + + <div class="ui small basic modal" id="edit-empty-content-modal"> + <div class="ui icon header"> + <i class="file icon"></i> + {{.i18n.Tr "repo.editor.commit_empty_file_header"}} + </div> + <div class="center content"> + <p>{{.i18n.Tr "repo.editor.commit_empty_file_text"}}</p> + </div> + <div class="actions"> + <div class="ui red basic cancel inverted button"> + <i class="remove icon"></i> + {{.i18n.Tr "repo.editor.cancel"}} + </div> + <div class="ui green basic ok inverted button"> + <i class="save icon"></i> + {{.i18n.Tr "repo.editor.commit_changes"}} + </div> + </div> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 6d525c24da..30f1471c16 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -89,6 +89,11 @@ {{.i18n.Tr "repo.editor.upload_file"}} </a> {{end}} + {{if .CanAddFile}} + <a href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}" class="ui button"> + {{.i18n.Tr "repo.editor.patch"}} + </a> + {{end}} {{end}} {{if and (ne $n 0) (not .IsViewFile) (not .IsBlame) }} <a href="{{.RepoLink}}/commits/{{.BranchNameSubURL}}/{{.TreePath | PathEscapeSegments}}" class="ui button"> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 497636e781..0b0b83ebbc 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3383,6 +3383,50 @@ } } }, + "/repos/{owner}/{repo}/diffpatch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Apply diff patch to repository", + "operationId": "repoApplyDiffPatch", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateFileOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/FileResponse" + } + } + } + }, "/repos/{owner}/{repo}/editorconfig/{filepath}": { "get": { "produces": [ diff --git a/web_src/js/components/RepoBranchTagDropdown.js b/web_src/js/components/RepoBranchTagDropdown.js index e72563ff29..d55fa91b92 100644 --- a/web_src/js/components/RepoBranchTagDropdown.js +++ b/web_src/js/components/RepoBranchTagDropdown.js @@ -10,11 +10,22 @@ export function initRepoBranchTagDropdown(selector) { items: [], mode: $data.data('mode'), searchTerm: '', + refName: '', noResults: '', canCreateBranch: false, menuVisible: false, createTag: false, - active: 0 + isViewTag: false, + isViewBranch: false, + isViewTree: false, + active: 0, + branchForm: '', + branchURLPrefix: '', + branchURLSuffix: '', + tagURLPrefix: '', + tagURLSuffix: '', + setAction: false, + submitForm: false, }; $data.find('.item').each(function () { data.items.push({ @@ -64,6 +75,26 @@ export function initRepoBranchTagDropdown(selector) { beforeMount() { this.noResults = this.$el.getAttribute('data-no-results'); this.canCreateBranch = this.$el.getAttribute('data-can-create-branch') === 'true'; + this.branchForm = this.$el.getAttribute('data-branch-form'); + switch (this.$el.getAttribute('data-view-type')) { + case 'tree': + this.isViewTree = true; + break; + case 'tag': + this.isViewTag = true; + break; + default: + this.isViewBranch = true; + break; + } + this.refName = this.$el.getAttribute('data-ref-name'); + this.branchURLPrefix = this.$el.getAttribute('data-branch-url-prefix'); + this.branchURLSuffix = this.$el.getAttribute('data-branch-url-suffix'); + this.tagURLPrefix = this.$el.getAttribute('data-tag-url-prefix'); + this.tagURLSuffix = this.$el.getAttribute('data-tag-url-suffix'); + this.setAction = this.$el.getAttribute('data-set-action') === 'true'; + this.submitForm = this.$el.getAttribute('data-submit-form') === 'true'; + document.body.addEventListener('click', (event) => { if (this.$el.contains(event.target)) return; @@ -80,7 +111,32 @@ export function initRepoBranchTagDropdown(selector) { prev.selected = false; } item.selected = true; - window.location.href = item.url; + const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix; + if (this.branchForm === '') { + window.location.href = url; + } else { + this.isViewTree = false; + this.isViewTag = false; + this.isViewBranch = false; + this.$refs.dropdownRefName.textContent = item.name; + if (this.setAction) { + $(`#${this.branchForm}`).attr('action', url); + } else { + $(`#${this.branchForm} input[name="refURL"]`).val(url); + } + $(`#${this.branchForm} input[name="ref"]`).val(item.name); + if (item.tag) { + this.isViewTag = true; + $(`#${this.branchForm} input[name="refType"]`).val('tag'); + } else { + this.isViewBranch = true; + $(`#${this.branchForm} input[name="refType"]`).val('branch'); + } + if (this.submitForm) { + $(`#${this.branchForm}`).trigger('submit'); + } + Vue.set(this, 'menuVisible', false); + } }, createNewBranch() { if (!this.showCreateNewBranch) return; diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index 84b67a68e1..45bb96c26e 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -313,9 +313,22 @@ export function initGlobalButtons() { alert('Nothing to hide'); }); - $('.show-modal.button').on('click', function () { - $($(this).data('modal')).modal('show'); - const colorPickers = $($(this).data('modal')).find('.color-picker'); + $('.show-modal').on('click', function () { + const modalDiv = $($(this).attr('data-modal')); + for (const attrib of this.attributes) { + if (!attrib.name.startsWith('data-modal-')) { + continue; + } + const id = attrib.name.substring(11); + const target = modalDiv.find(`#${id}`); + if (target.is('input')) { + target.val(attrib.value); + } else { + target.text(attrib.value); + } + } + modalDiv.modal('show'); + const colorPickers = $($(this).attr('data-modal')).find('.color-picker'); if (colorPickers.length > 0) { initCompColorPicker(); } @@ -323,10 +336,10 @@ export function initGlobalButtons() { $('.delete-post.button').on('click', function () { const $this = $(this); - $.post($this.data('request-url'), { + $.post($this.attr('data-request-url'), { _csrf: csrfToken }).done(() => { - window.location.href = $this.data('done-url'); + window.location.href = $this.attr('data-done-url'); }); }); } diff --git a/web_src/js/features/repo-branch.js b/web_src/js/features/repo-branch.js index bc713a7397..946f7f90a4 100644 --- a/web_src/js/features/repo-branch.js +++ b/web_src/js/features/repo-branch.js @@ -1,9 +1,18 @@ import $ from 'jquery'; export function initRepoBranchButton() { - $('.show-create-branch-modal.button').on('click', function () { - $('#create-branch-form')[0].action = $('#create-branch-form').data('base-action') + $(this).data('branch-from-urlcomponent'); - $('#modal-create-branch-from-span').text($(this).data('branch-from')); - $($(this).data('modal')).modal('show'); + $('.show-create-branch-modal').on('click', function () { + let modalFormName = $(this).attr('data-modal-form'); + if (!modalFormName) { + modalFormName = '#create-branch-form'; + } + $(modalFormName)[0].action = $(modalFormName).attr('data-base-action') + $(this).attr('data-branch-from-urlcomponent'); + let fromSpanName = $(this).attr('data-modal-from-span'); + if (!fromSpanName) { + fromSpanName = '#modal-create-branch-from-span'; + } + + $(fromSpanName).text($(this).attr('data-branch-from')); + $($(this).attr('data-modal')).modal('show'); }); } diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index d92a0539d0..d51dfe185c 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -436,7 +436,7 @@ export function initRepository() { }); // File list and commits - if ($('.repository.file.list').length > 0 || + if ($('.repository.file.list').length > 0 || $('.branch-dropdown').length > 0 || $('.repository.commits').length > 0 || $('.repository.release').length > 0) { initRepoBranchTagDropdown('.choose.reference .dropdown'); } diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 6cf70abdf7..8b912ce90d 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -2551,12 +2551,6 @@ padding-top: 15px; } -.browse-button { - position: absolute; - right: 1rem; - top: .75rem; -} - .commit-header-row { min-height: 50px !important; padding-top: 0 !important; |