mirror of
https://github.com/go-gitea/gitea.git
synced 2026-01-31 12:14:18 +01:00
Most potential deadlock problems should have been fixed, and new code is unlikely to cause new problems with the new design. Also raise the minimum Git version required to 2.6.0 (released in 2015)
316 lines
11 KiB
Go
316 lines
11 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pull
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
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/git/gitcmd"
|
|
"code.gitea.io/gitea/modules/gitrepo"
|
|
"code.gitea.io/gitea/modules/log"
|
|
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
|
)
|
|
|
|
type mergeContext struct {
|
|
*prTmpRepoContext
|
|
doer *user_model.User
|
|
sig *git.Signature
|
|
committer *git.Signature
|
|
signKey *git.SigningKey
|
|
env []string
|
|
}
|
|
|
|
// PrepareGitCmd prepares a git command with the correct directory, environment, and output buffers
|
|
// This function can only be called with gitcmd.Run()
|
|
// Do NOT use it with gitcmd.RunStd*() functions, otherwise it will panic
|
|
func (ctx *mergeContext) PrepareGitCmd(cmd *gitcmd.Command) *gitcmd.Command {
|
|
ctx.outbuf.Reset()
|
|
return cmd.WithEnv(ctx.env).
|
|
WithDir(ctx.tmpBasePath).
|
|
WithParentCallerInfo().
|
|
WithStdoutBuffer(ctx.outbuf)
|
|
}
|
|
|
|
// ErrSHADoesNotMatch represents a "SHADoesNotMatch" kind of error.
|
|
type ErrSHADoesNotMatch struct {
|
|
Path string
|
|
GivenSHA string
|
|
CurrentSHA string
|
|
}
|
|
|
|
// IsErrSHADoesNotMatch checks if an error is a ErrSHADoesNotMatch.
|
|
func IsErrSHADoesNotMatch(err error) bool {
|
|
_, ok := err.(ErrSHADoesNotMatch)
|
|
return ok
|
|
}
|
|
|
|
func (err ErrSHADoesNotMatch) Error() string {
|
|
return fmt.Sprintf("sha does not match [given: %s, expected: %s]", err.GivenSHA, err.CurrentSHA)
|
|
}
|
|
|
|
func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, expectedHeadCommitID string) (mergeCtx *mergeContext, cancel context.CancelFunc, err error) {
|
|
// Clone base repo.
|
|
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
|
if err != nil {
|
|
log.Error("createTemporaryRepoForPR: %v", err)
|
|
return nil, cancel, err
|
|
}
|
|
|
|
mergeCtx = &mergeContext{
|
|
prTmpRepoContext: prCtx,
|
|
doer: doer,
|
|
}
|
|
|
|
if expectedHeadCommitID != "" {
|
|
trackingCommitID, _, err := gitcmd.NewCommand("show-ref", "--hash").
|
|
AddDynamicArguments(git.BranchPrefix + trackingBranch).
|
|
WithEnv(mergeCtx.env).
|
|
WithDir(mergeCtx.tmpBasePath).
|
|
RunStdString(ctx)
|
|
if err != nil {
|
|
defer cancel()
|
|
log.Error("failed to get sha of head branch in %-v: show-ref[%s] --hash refs/heads/tracking: %v", mergeCtx.pr, mergeCtx.tmpBasePath, err)
|
|
return nil, nil, fmt.Errorf("unable to get sha of head branch in pr[%d]: %w", pr.ID, err)
|
|
}
|
|
if strings.TrimSpace(trackingCommitID) != expectedHeadCommitID {
|
|
defer cancel()
|
|
return nil, nil, ErrSHADoesNotMatch{
|
|
GivenSHA: expectedHeadCommitID,
|
|
CurrentSHA: trackingCommitID,
|
|
}
|
|
}
|
|
}
|
|
|
|
mergeCtx.outbuf.Reset()
|
|
if err := prepareTemporaryRepoForMerge(mergeCtx); err != nil {
|
|
defer cancel()
|
|
return nil, nil, err
|
|
}
|
|
|
|
mergeCtx.sig = doer.NewGitSig()
|
|
mergeCtx.committer = mergeCtx.sig
|
|
|
|
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
|
if err != nil {
|
|
defer cancel()
|
|
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
|
}
|
|
defer gitRepo.Close()
|
|
|
|
// Determine if we should sign
|
|
sign, key, signer, _ := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
|
|
if sign {
|
|
mergeCtx.signKey = key
|
|
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
|
mergeCtx.committer = signer
|
|
}
|
|
}
|
|
|
|
commitTimeStr := time.Now().Format(time.RFC3339)
|
|
|
|
// Because this may call hooks we should pass in the environment
|
|
mergeCtx.env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME="+mergeCtx.sig.Name,
|
|
"GIT_AUTHOR_EMAIL="+mergeCtx.sig.Email,
|
|
"GIT_AUTHOR_DATE="+commitTimeStr,
|
|
"GIT_COMMITTER_NAME="+mergeCtx.committer.Name,
|
|
"GIT_COMMITTER_EMAIL="+mergeCtx.committer.Email,
|
|
"GIT_COMMITTER_DATE="+commitTimeStr,
|
|
)
|
|
|
|
return mergeCtx, cancel, nil
|
|
}
|
|
|
|
// prepareTemporaryRepoForMerge takes a repository that has been created using createTemporaryRepo
|
|
// it then sets up the sparse-checkout and other things
|
|
func prepareTemporaryRepoForMerge(ctx *mergeContext) error {
|
|
infoPath := filepath.Join(ctx.tmpBasePath, ".git", "info")
|
|
if err := os.MkdirAll(infoPath, 0o700); err != nil {
|
|
log.Error("%-v Unable to create .git/info in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
|
return fmt.Errorf("Unable to create .git/info in tmpBasePath: %w", err)
|
|
}
|
|
|
|
// Enable sparse-checkout
|
|
// Here we use the .git/info/sparse-checkout file as described in the git documentation
|
|
sparseCheckoutListFile, err := os.OpenFile(filepath.Join(infoPath, "sparse-checkout"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
log.Error("%-v Unable to write .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
|
return fmt.Errorf("Unable to write .git/info/sparse-checkout file in tmpBasePath: %w", err)
|
|
}
|
|
defer sparseCheckoutListFile.Close() // we will close it earlier but we need to ensure it is closed if there is an error
|
|
|
|
if err := getDiffTree(ctx, ctx.tmpBasePath, baseBranch, trackingBranch, sparseCheckoutListFile); err != nil {
|
|
log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, baseBranch, trackingBranch, err)
|
|
return fmt.Errorf("getDiffTree: %w", err)
|
|
}
|
|
|
|
if err := sparseCheckoutListFile.Close(); err != nil {
|
|
log.Error("%-v Unable to close .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
|
return fmt.Errorf("Unable to close .git/info/sparse-checkout file in tmpBasePath: %w", err)
|
|
}
|
|
|
|
setConfig := func(key, value string) error {
|
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("config", "--local").AddDynamicArguments(key, value)).
|
|
RunWithStderr(ctx); err != nil {
|
|
log.Error("git config [%s -> %q]: %v\n%s\n%s", key, value, err, ctx.outbuf.String(), err.Stderr())
|
|
return fmt.Errorf("git config [%s -> %q]: %w\n%s\n%s", key, value, err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
ctx.outbuf.Reset()
|
|
return nil
|
|
}
|
|
|
|
// Switch off LFS process (set required, clean and smudge here also)
|
|
if err := setConfig("filter.lfs.process", ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := setConfig("filter.lfs.required", "false"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := setConfig("filter.lfs.clean", ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := setConfig("filter.lfs.smudge", ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := setConfig("core.sparseCheckout", "true"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Read base branch index
|
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("read-tree", "HEAD")).
|
|
RunWithStderr(ctx); err != nil {
|
|
log.Error("git read-tree HEAD: %v\n%s\n%s", err, ctx.outbuf.String(), err.Stderr())
|
|
return fmt.Errorf("Unable to read base branch in to the index: %w\n%s\n%s", err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
ctx.outbuf.Reset()
|
|
return nil
|
|
}
|
|
|
|
// getDiffTree returns a string containing all the files that were changed between headBranch and baseBranch
|
|
// the filenames are escaped so as to fit the format required for .git/info/sparse-checkout
|
|
func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, out io.Writer) error {
|
|
scanNullTerminatedStrings := func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
|
if atEOF && len(data) == 0 {
|
|
return 0, nil, nil
|
|
}
|
|
if i := bytes.IndexByte(data, '\x00'); i >= 0 {
|
|
return i + 1, data[0:i], nil
|
|
}
|
|
if atEOF {
|
|
return len(data), data, nil
|
|
}
|
|
return 0, nil, nil
|
|
}
|
|
|
|
cmd := gitcmd.NewCommand("diff-tree", "--no-commit-id", "--name-only", "-r", "-r", "-z", "--root")
|
|
diffOutReader, diffOutReaderClose := cmd.MakeStdoutPipe()
|
|
defer diffOutReaderClose()
|
|
err := cmd.AddDynamicArguments(baseBranch, headBranch).
|
|
WithDir(repoPath).
|
|
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
|
// Now scan the output from the command
|
|
scanner := bufio.NewScanner(diffOutReader)
|
|
scanner.Split(scanNullTerminatedStrings)
|
|
for scanner.Scan() {
|
|
treePath := scanner.Text()
|
|
// escape '*', '?', '[', spaces and '!' prefix
|
|
treePath = escapedSymbols.ReplaceAllString(treePath, `\$1`)
|
|
// no necessary to escape the first '#' symbol because the first symbol is '/'
|
|
if _, err := fmt.Fprintf(out, "/%s\n", treePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}).
|
|
Run(ctx)
|
|
return err
|
|
}
|
|
|
|
// ErrRebaseConflicts represents an error if rebase fails with a conflict
|
|
type ErrRebaseConflicts struct {
|
|
Style repo_model.MergeStyle
|
|
CommitSHA string
|
|
StdOut string
|
|
StdErr string
|
|
Err error
|
|
}
|
|
|
|
// IsErrRebaseConflicts checks if an error is a ErrRebaseConflicts.
|
|
func IsErrRebaseConflicts(err error) bool {
|
|
_, ok := err.(ErrRebaseConflicts)
|
|
return ok
|
|
}
|
|
|
|
func (err ErrRebaseConflicts) Error() string {
|
|
return fmt.Sprintf("Rebase Error: %v: Whilst Rebasing: %s\n%s\n%s", err.Err, err.CommitSHA, err.StdErr, err.StdOut)
|
|
}
|
|
|
|
// rebaseTrackingOnToBase checks out the tracking branch as staging and rebases it on to the base branch
|
|
// if there is a conflict it will return an ErrRebaseConflicts
|
|
func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error {
|
|
// Checkout head branch
|
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout", "-b").AddDynamicArguments(stagingBranch, trackingBranch)).
|
|
RunWithStderr(ctx); err != nil {
|
|
return fmt.Errorf("unable to git checkout tracking as staging in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
ctx.outbuf.Reset()
|
|
|
|
// Rebase before merging
|
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("rebase").AddDynamicArguments(baseBranch)).
|
|
RunWithStderr(ctx); err != nil {
|
|
// Rebase will leave a REBASE_HEAD file in .git if there is a conflict
|
|
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil {
|
|
var commitSha string
|
|
ok := false
|
|
failingCommitPaths := []string{
|
|
filepath.Join(ctx.tmpBasePath, ".git", "rebase-apply", "original-commit"), // Git < 2.26
|
|
filepath.Join(ctx.tmpBasePath, ".git", "rebase-merge", "stopped-sha"), // Git >= 2.26
|
|
}
|
|
for _, failingCommitPath := range failingCommitPaths {
|
|
if _, statErr := os.Stat(failingCommitPath); statErr == nil {
|
|
commitShaBytes, readErr := os.ReadFile(failingCommitPath)
|
|
if readErr != nil {
|
|
// Abandon this attempt to handle the error
|
|
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
commitSha = strings.TrimSpace(string(commitShaBytes))
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
log.Error("Unable to determine failing commit sha for failing rebase in temp repo for %-v. Cannot cast as ErrRebaseConflicts.", ctx.pr)
|
|
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
log.Debug("Conflict when rebasing staging on to base in %-v at %s: %v\n%s\n%s", ctx.pr, commitSha, err, ctx.outbuf.String(), err.Stderr())
|
|
return ErrRebaseConflicts{
|
|
CommitSHA: commitSha,
|
|
Style: mergeStyle,
|
|
StdOut: ctx.outbuf.String(),
|
|
StdErr: err.Stderr(),
|
|
Err: err,
|
|
}
|
|
}
|
|
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
|
}
|
|
ctx.outbuf.Reset()
|
|
return nil
|
|
}
|