summaryrefslogtreecommitdiffstats
path: root/services
diff options
context:
space:
mode:
authorzeripath <art27@cantab.net>2022-02-09 20:28:55 +0000
committerGitHub <noreply@github.com>2022-02-09 20:28:55 +0000
commiteb748f5f3c93e8e347309fc75ea8273c06a5489b (patch)
treefceec474a21fa35437bcf3e90bd549c11976b72e /services
parent439ad34c71b8777a9dac369c643560b18bc41bab (diff)
downloadgitea-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>
Diffstat (limited to 'services')
-rw-r--r--services/forms/repo_form.go24
-rw-r--r--services/pull/patch.go37
-rw-r--r--services/repository/files/cherry_pick.go126
-rw-r--r--services/repository/files/patch.go193
4 files changed, 367 insertions, 13 deletions
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
+}