mirror of
https://github.com/go-gitea/gitea
synced 2025-07-22 18:28:37 +00:00
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>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
// ____ ___ .__ .___
|
||||
// | | \______ | | _________ __| _/
|
||||
// | | /\____ \| | / _ \__ \ / __ |
|
||||
|
@@ -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)
|
||||
|
126
services/repository/files/cherry_pick.go
Normal file
126
services/repository/files/cherry_pick.go
Normal file
@@ -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
|
||||
}
|
193
services/repository/files/patch.go
Normal file
193
services/repository/files/patch.go
Normal file
@@ -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
|
||||
}
|
Reference in New Issue
Block a user