mirror of
https://github.com/go-gitea/gitea
synced 2026-02-03 11:10:40 +00:00
Use merge tree to detect conflicts when possible (#36400)
In Git 2.38, the `merge-tree` command introduced the `--write-tree` option, which works directly on bare repositories. In Git 2.40, a new parameter `--merge-base` introduced so we require Git 2.40 to use the merge tree feature. This option produces the merged tree object ID, allowing us to perform diffs between commits without creating a temporary repository. By avoiding the overhead of setting up and tearing down temporary repos, this approach delivers a notable performance improvement. It also fixes a possible situation that conflict files might be empty but it's a conflict status according to https://git-scm.com/docs/git-merge-tree#_mistakes_to_avoid Replace #35542 --------- Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -667,8 +667,9 @@ func HasWorkInProgressPrefix(title string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch.
|
// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch.
|
||||||
|
// Sometimes a conflict may not list any files
|
||||||
func (pr *PullRequest) IsFilesConflicted() bool {
|
func (pr *PullRequest) IsFilesConflicted() bool {
|
||||||
return len(pr.ConflictedFiles) > 0
|
return pr.Status == PullRequestStatusConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress.
|
// GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Features struct {
|
|||||||
SupportedObjectFormats []ObjectFormat // sha1, sha256
|
SupportedObjectFormats []ObjectFormat // sha1, sha256
|
||||||
SupportCheckAttrOnBare bool // >= 2.40
|
SupportCheckAttrOnBare bool // >= 2.40
|
||||||
SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command`
|
SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command`
|
||||||
|
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultFeatures *Features
|
var defaultFeatures *Features
|
||||||
@@ -77,6 +78,7 @@ func loadGitVersionFeatures() (*Features, error) {
|
|||||||
}
|
}
|
||||||
features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40")
|
features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40")
|
||||||
features.SupportCatFileBatchCommand = features.CheckVersionAtLeast("2.36")
|
features.SupportCatFileBatchCommand = features.CheckVersionAtLeast("2.36")
|
||||||
|
features.SupportGitMergeTree = features.CheckVersionAtLeast("2.40") // we also need "--merge-base"
|
||||||
return features, nil
|
return features, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Command struct {
|
|||||||
cmdStartTime time.Time
|
cmdStartTime time.Time
|
||||||
|
|
||||||
parentPipeFiles []*os.File
|
parentPipeFiles []*os.File
|
||||||
|
parentPipeReaders []*os.File
|
||||||
childrenPipeFiles []*os.File
|
childrenPipeFiles []*os.File
|
||||||
|
|
||||||
// only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly
|
// only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly
|
||||||
@@ -283,6 +284,7 @@ func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) {
|
|||||||
}
|
}
|
||||||
c.childrenPipeFiles = append(c.childrenPipeFiles, pw)
|
c.childrenPipeFiles = append(c.childrenPipeFiles, pw)
|
||||||
c.parentPipeFiles = append(c.parentPipeFiles, pr)
|
c.parentPipeFiles = append(c.parentPipeFiles, pr)
|
||||||
|
c.parentPipeReaders = append(c.parentPipeReaders, pr)
|
||||||
*w /* stdout, stderr */ = pw
|
*w /* stdout, stderr */ = pw
|
||||||
return &pipeReader{f: pr}, func() { pr.Close() }
|
return &pipeReader{f: pr}, func() { pr.Close() }
|
||||||
}
|
}
|
||||||
@@ -348,7 +350,13 @@ func (c *Command) WithStdoutCopy(w io.Writer) *Command {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Command) WithPipelineFunc(f func(Context) error) *Command {
|
// WithPipelineFunc sets the pipeline function for the command.
|
||||||
|
// The pipeline function will be called in the Run / Wait function after the command is started successfully.
|
||||||
|
// The function can read/write from/to the command's stdio pipes (if any).
|
||||||
|
// The pipeline function can cancel (kill) the command by calling ctx.CancelPipeline before the command finishes.
|
||||||
|
// The returned error of Run / Wait can be joined errors from the pipeline function, context cause, and command exit error.
|
||||||
|
// Caller can get the pipeline function's error (if any) by UnwrapPipelineError.
|
||||||
|
func (c *Command) WithPipelineFunc(f func(ctx Context) error) *Command {
|
||||||
c.opts.PipelineFunc = f
|
c.opts.PipelineFunc = f
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -444,6 +452,12 @@ func (c *Command) closePipeFiles(files []*os.File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Command) discardPipeReaders(files []*os.File) {
|
||||||
|
for _, f := range files {
|
||||||
|
_, _ = io.Copy(io.Discard, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Command) Wait() error {
|
func (c *Command) Wait() error {
|
||||||
defer func() {
|
defer func() {
|
||||||
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
|
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
|
||||||
@@ -454,15 +468,31 @@ func (c *Command) Wait() error {
|
|||||||
|
|
||||||
if c.opts.PipelineFunc != nil {
|
if c.opts.PipelineFunc != nil {
|
||||||
errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c})
|
errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c})
|
||||||
// after the pipeline function returns, we can safely cancel the command context and close the pipes, the data in pipes should have been consumed
|
|
||||||
c.cmdCancel(errPipeline)
|
if context.Cause(c.cmdCtx) == nil {
|
||||||
|
// if the context is not canceled explicitly, we need to discard the unread data,
|
||||||
|
// and wait for the command to exit normally, and then get its exit code
|
||||||
|
c.discardPipeReaders(c.parentPipeReaders)
|
||||||
|
} // else: canceled command will be killed, and the exit code is caused by kill
|
||||||
|
|
||||||
|
// after the pipeline function returns, we can safely close the pipes, then wait for the command to exit
|
||||||
c.closePipeFiles(c.parentPipeFiles)
|
c.closePipeFiles(c.parentPipeFiles)
|
||||||
errWait := c.cmd.Wait()
|
errWait := c.cmd.Wait()
|
||||||
errCause := context.Cause(c.cmdCtx)
|
errCause := context.Cause(c.cmdCtx) // in case the cause is set during Wait(), get the final cancel cause
|
||||||
// the pipeline function should be able to know whether it succeeds or fails
|
|
||||||
if errPipeline == nil && (errCause == nil || errors.Is(errCause, context.Canceled)) {
|
if unwrapped, ok := UnwrapPipelineError(errCause); ok {
|
||||||
return nil
|
if unwrapped != errPipeline {
|
||||||
|
panic("unwrapped context pipeline error should be the same one returned by pipeline function")
|
||||||
}
|
}
|
||||||
|
if unwrapped == nil {
|
||||||
|
// the pipeline function declares that there is no error, and it cancels (kills) the command ahead,
|
||||||
|
// so we should ignore the errors from "wait" and "cause"
|
||||||
|
errWait, errCause = nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// some legacy code still need to access the error returned by pipeline function by "==" but not "errors.Is"
|
||||||
|
// so we need to make sure the original error is able to be unwrapped by UnwrapPipelineError
|
||||||
return errors.Join(wrapPipelineError(errPipeline), errCause, errWait)
|
return errors.Join(wrapPipelineError(errPipeline), errCause, errWait)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
type Context interface {
|
type Context interface {
|
||||||
context.Context
|
context.Context
|
||||||
|
|
||||||
// CancelWithCause is a helper function to cancel the context with a specific error cause
|
// CancelPipeline is a helper function to cancel the command context (kill the command) with a specific error cause,
|
||||||
// And it returns the same error for convenience, to break the PipelineFunc easily
|
// it returns the same error for convenience to break the PipelineFunc easily
|
||||||
CancelWithCause(err error) error
|
CancelPipeline(err error) error
|
||||||
|
|
||||||
// In the future, this interface will be extended to support stdio pipe readers/writers
|
// In the future, this interface will be extended to support stdio pipe readers/writers
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,11 @@ type cmdContext struct {
|
|||||||
cmd *Command
|
cmd *Command
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cmdContext) CancelWithCause(err error) error {
|
func (c *cmdContext) CancelPipeline(err error) error {
|
||||||
c.cmd.cmdCancel(err)
|
// pipelineError is used to distinguish between:
|
||||||
|
// * context canceled by pipeline caller with/without error (normal cancellation)
|
||||||
|
// * context canceled by parent context (still context.Canceled error)
|
||||||
|
// * other causes
|
||||||
|
c.cmd.cmdCancel(pipelineError{err})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ func wrapPipelineError(err error) error {
|
|||||||
return pipelineError{err}
|
return pipelineError{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrorAsPipeline(err error) error {
|
func UnwrapPipelineError(err error) (error, bool) { //nolint:revive // this is for error unwrapping
|
||||||
var pipelineErr pipelineError
|
var pe pipelineError
|
||||||
if errors.As(err, &pipelineErr) {
|
if errors.As(err, &pe) {
|
||||||
return pipelineErr.error
|
return pe.error, true
|
||||||
}
|
}
|
||||||
return nil
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
|
|||||||
}
|
}
|
||||||
if line == "" {
|
if line == "" {
|
||||||
if len(results) >= opts.MaxResultLimit {
|
if len(results) >= opts.MaxResultLimit {
|
||||||
return ctx.CancelWithCause(nil)
|
return ctx.CancelPipeline(nil)
|
||||||
}
|
}
|
||||||
isInBlock = false
|
isInBlock = false
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedC
|
|||||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||||
defer stdoutReaderClose()
|
defer stdoutReaderClose()
|
||||||
cmd.WithDir(repoPath).
|
cmd.WithDir(repoPath).
|
||||||
WithPipelineFunc(func(c gitcmd.Context) error {
|
WithPipelineFunc(func(gitcmd.Context) error {
|
||||||
bufReader := bufio.NewReader(stdoutReader)
|
bufReader := bufio.NewReader(stdoutReader)
|
||||||
for i < skip {
|
for i < skip {
|
||||||
_, isPrefix, err := bufReader.ReadLine()
|
_, isPrefix, err := bufReader.ReadLine()
|
||||||
@@ -165,7 +165,7 @@ func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedC
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
err = cmd.RunWithStderr(ctx)
|
err = cmd.RunWithStderr(ctx)
|
||||||
if errPipeline := gitcmd.ErrorAsPipeline(err); errPipeline != nil {
|
if errPipeline, ok := gitcmd.UnwrapPipelineError(err); ok {
|
||||||
return i, errPipeline // keep the old behavior: return pipeline error directly
|
return i, errPipeline // keep the old behavior: return pipeline error directly
|
||||||
}
|
}
|
||||||
return i, err
|
return i, err
|
||||||
|
|||||||
@@ -4,29 +4,21 @@
|
|||||||
package gitrepo
|
package gitrepo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
|
||||||
"code.gitea.io/gitea/modules/tempdir"
|
|
||||||
"code.gitea.io/gitea/modules/test"
|
"code.gitea.io/gitea/modules/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Unable to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// resolve repository path relative to the test directory
|
// resolve repository path relative to the test directory
|
||||||
testRootDir := test.SetupGiteaRoot()
|
testRootDir := test.SetupGiteaRoot()
|
||||||
repoPath = func(repo Repository) string {
|
repoPath = func(repo Repository) string {
|
||||||
return filepath.Join(testRootDir, "/modules/git/tests/repos", repo.RelativePath())
|
if filepath.IsAbs(repo.RelativePath()) {
|
||||||
|
return repo.RelativePath() // for testing purpose only
|
||||||
}
|
}
|
||||||
|
return filepath.Join(testRootDir, "modules/git/tests/repos", repo.RelativePath())
|
||||||
setting.Git.HomePath = gitHomePath
|
}
|
||||||
os.Exit(m.Run())
|
git.RunGitTests(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gitrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxConflictedDetectFiles = 10
|
||||||
|
|
||||||
|
// MergeTree performs a merge between two commits (baseRef and headRef) with an optional merge base.
|
||||||
|
// It returns the resulting tree hash, a list of conflicted files (if any), and an error if the operation fails.
|
||||||
|
// If there are no conflicts, the list of conflicted files will be nil.
|
||||||
|
func MergeTree(ctx context.Context, repo Repository, baseRef, headRef, mergeBase string) (treeID string, isErrHasConflicts bool, conflictFiles []string, _ error) {
|
||||||
|
cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages").
|
||||||
|
AddOptionFormat("--merge-base=%s", mergeBase).
|
||||||
|
AddDynamicArguments(baseRef, headRef)
|
||||||
|
|
||||||
|
stdout, stdoutClose := cmd.MakeStdoutPipe()
|
||||||
|
defer stdoutClose()
|
||||||
|
cmd.WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||||
|
// https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT
|
||||||
|
// For a conflicted merge, the output is:
|
||||||
|
// <OID of toplevel tree>NUL
|
||||||
|
// <Conflicted file name 1>NUL
|
||||||
|
// <Conflicted file name 2>NUL
|
||||||
|
// ...
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Split(util.BufioScannerSplit(0))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if treeID == "" { // first line is tree ID
|
||||||
|
treeID = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conflictFiles = append(conflictFiles, line)
|
||||||
|
if len(conflictFiles) >= MaxConflictedDetectFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
})
|
||||||
|
|
||||||
|
err := RunCmdWithStderr(ctx, repo, cmd)
|
||||||
|
// For a successful, non-conflicted merge, the exit status is 0. When the merge has conflicts, the exit status is 1.
|
||||||
|
// A merge can have conflicts without having individual files conflict
|
||||||
|
// https://git-scm.com/docs/git-merge-tree/2.38.0#_mistakes_to_avoid
|
||||||
|
isErrHasConflicts = gitcmd.IsErrorExitCode(err, 1)
|
||||||
|
if err == nil || isErrHasConflicts {
|
||||||
|
return treeID, isErrHasConflicts, conflictFiles, nil
|
||||||
|
}
|
||||||
|
return "", false, nil, fmt.Errorf("run merge-tree failed: %w", err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package gitrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareRepoDirRenameConflict(t *testing.T) string {
|
||||||
|
repoDir := filepath.Join(t.TempDir(), "repo-dir-rename-conflict.git")
|
||||||
|
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
|
||||||
|
stdin := `blob
|
||||||
|
mark :1
|
||||||
|
data 2
|
||||||
|
b
|
||||||
|
|
||||||
|
blob
|
||||||
|
mark :2
|
||||||
|
data 2
|
||||||
|
c
|
||||||
|
|
||||||
|
reset refs/heads/master
|
||||||
|
commit refs/heads/master
|
||||||
|
mark :3
|
||||||
|
author test <test@example.com> 1769202331 -0800
|
||||||
|
committer test <test@example.com> 1769202331 -0800
|
||||||
|
data 2
|
||||||
|
O
|
||||||
|
M 100644 :1 z/b
|
||||||
|
M 100644 :2 z/c
|
||||||
|
|
||||||
|
commit refs/heads/split
|
||||||
|
mark :4
|
||||||
|
author test <test@example.com> 1769202336 -0800
|
||||||
|
committer test <test@example.com> 1769202336 -0800
|
||||||
|
data 2
|
||||||
|
A
|
||||||
|
from :3
|
||||||
|
M 100644 :2 w/c
|
||||||
|
M 100644 :1 y/b
|
||||||
|
D z/b
|
||||||
|
D z/c
|
||||||
|
|
||||||
|
blob
|
||||||
|
mark :5
|
||||||
|
data 2
|
||||||
|
d
|
||||||
|
|
||||||
|
commit refs/heads/add
|
||||||
|
mark :6
|
||||||
|
author test <test@example.com> 1769202342 -0800
|
||||||
|
committer test <test@example.com> 1769202342 -0800
|
||||||
|
data 2
|
||||||
|
B
|
||||||
|
from :3
|
||||||
|
M 100644 :5 z/d
|
||||||
|
`
|
||||||
|
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
|
||||||
|
return repoDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeTreeDirectoryRenameConflictWithoutFiles(t *testing.T) {
|
||||||
|
repoDir := prepareRepoDirRenameConflict(t)
|
||||||
|
require.DirExists(t, repoDir)
|
||||||
|
repo := &mockRepository{path: repoDir}
|
||||||
|
|
||||||
|
mergeBase, err := MergeBase(t.Context(), repo, "add", "split")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
treeID, conflicted, conflictedFiles, err := MergeTree(t.Context(), repo, "add", "split", mergeBase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, conflicted)
|
||||||
|
assert.Empty(t, conflictedFiles)
|
||||||
|
assert.Equal(t, "5e3dd4cfc5b11e278a35b2daa83b7274175e3ab1", treeID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
func BufioScannerSplit(b byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
// reference: bufio.ScanLines
|
||||||
|
return 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, b); i >= 0 {
|
||||||
|
return i + 1, data[0:i], nil
|
||||||
|
}
|
||||||
|
if atEOF {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1796,6 +1796,7 @@
|
|||||||
"repo.pulls.remove_prefix": "Remove <strong>%s</strong> prefix",
|
"repo.pulls.remove_prefix": "Remove <strong>%s</strong> prefix",
|
||||||
"repo.pulls.data_broken": "This pull request is broken due to missing fork information.",
|
"repo.pulls.data_broken": "This pull request is broken due to missing fork information.",
|
||||||
"repo.pulls.files_conflicted": "This pull request has changes conflicting with the target branch.",
|
"repo.pulls.files_conflicted": "This pull request has changes conflicting with the target branch.",
|
||||||
|
"repo.pulls.files_conflicted_no_listed_files": "(No conflicting files listed)",
|
||||||
"repo.pulls.is_checking": "Checking for merge conflicts…",
|
"repo.pulls.is_checking": "Checking for merge conflicts…",
|
||||||
"repo.pulls.is_ancestor": "This branch is already included in the target branch. There is nothing to merge.",
|
"repo.pulls.is_ancestor": "This branch is already included in the target branch. There is nothing to merge.",
|
||||||
"repo.pulls.is_empty": "The changes on this branch are already on the target branch. This will be an empty commit.",
|
"repo.pulls.is_empty": "The changes on this branch are already on the target branch. This will be an empty commit.",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []
|
|||||||
WithDir(repo.Path).
|
WithDir(repo.Path).
|
||||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||||
err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env)
|
err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env)
|
||||||
return ctx.CancelWithCause(err)
|
return ctx.CancelPipeline(err)
|
||||||
}).
|
}).
|
||||||
Run(repo.Ctx)
|
Run(repo.Ctx)
|
||||||
if err != nil && !isErrUnverifiedCommit(err) {
|
if err != nil && !isErrUnverifiedCommit(err) {
|
||||||
@@ -70,7 +70,7 @@ func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error {
|
|||||||
}
|
}
|
||||||
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||||
if !verification.Verified {
|
if !verification.Verified {
|
||||||
return ctx.CancelWithCause(&errUnverifiedCommit{commit.ID.String()})
|
return ctx.CancelPipeline(&errUnverifiedCommit{commit.ID.String()})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}).
|
}).
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
|
|||||||
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status,
|
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status,
|
||||||
// and set to be either conflict or mergeable.
|
// and set to be either conflict or mergeable.
|
||||||
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
|
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
|
||||||
// If the status has not been changed to conflict by testPullRequestTmpRepoBranchMergeable then we are mergeable
|
// If the status has not been changed to conflict by the conflict checking functions then we are mergeable
|
||||||
if pr.Status == issues_model.PullRequestStatusChecking {
|
if pr.Status == issues_model.PullRequestStatusChecking {
|
||||||
pr.Status = issues_model.PullRequestStatusMergeable
|
pr.Status = issues_model.PullRequestStatusMergeable
|
||||||
}
|
}
|
||||||
@@ -442,8 +442,8 @@ func checkPullRequestMergeable(id int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := testPullRequestBranchMergeable(pr); err != nil {
|
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
||||||
log.Error("testPullRequestTmpRepoBranchMergeable[%-v]: %v", pr, err)
|
log.Error("checkPullRequestBranchMergeable[%-v]: %v", pr, err)
|
||||||
pr.Status = issues_model.PullRequestStatusError
|
pr.Status = issues_model.PullRequestStatusError
|
||||||
if err := pr.UpdateCols(ctx, "status"); err != nil {
|
if err := pr.UpdateCols(ctx, "status"); err != nil {
|
||||||
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
|
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
|
||||||
|
|||||||
@@ -366,11 +366,11 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
return "", fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
||||||
}
|
}
|
||||||
mergeBaseSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, "original_"+baseBranch)
|
mergeBaseSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, "original_"+tmpRepoBaseBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Failed to get full commit id for origin/%s: %w", pr.BaseBranch, err)
|
return "", fmt.Errorf("Failed to get full commit id for origin/%s: %w", pr.BaseBranch, err)
|
||||||
}
|
}
|
||||||
mergeCommitID, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, baseBranch)
|
mergeCommitID, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, tmpRepoBaseBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Failed to get full commit id for the new merge: %w", err)
|
return "", fmt.Errorf("Failed to get full commit id for the new merge: %w", err)
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
|
|||||||
)
|
)
|
||||||
|
|
||||||
mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
|
mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
|
||||||
pushCmd := gitcmd.NewCommand("push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
|
pushCmd := gitcmd.NewCommand("push", "origin").AddDynamicArguments(tmpRepoBaseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
|
||||||
|
|
||||||
// Push back to upstream.
|
// Push back to upstream.
|
||||||
// This cause an api call to "/api/internal/hook/post-receive/...",
|
// This cause an api call to "/api/internal/hook/post-receive/...",
|
||||||
@@ -717,7 +717,7 @@ func SetMerged(ctx context.Context, pr *issues_model.PullRequest, mergedCommitID
|
|||||||
return false, fmt.Errorf("ChangeIssueStatus: %w", err)
|
return false, fmt.Errorf("ChangeIssueStatus: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to save all of the data used to compute this merge as it may have already been changed by testPullRequestBranchMergeable. FIXME: need to set some state to prevent testPullRequestBranchMergeable from running whilst we are merging.
|
// We need to save all of the data used to compute this merge as it may have already been changed by checkPullRequestBranchMergeable. FIXME: need to set some state to prevent checkPullRequestBranchMergeable from running whilst we are merging.
|
||||||
if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
|
if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
|
||||||
And("has_merged = ?", false).
|
And("has_merged = ?", false).
|
||||||
Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").
|
Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch)
|
// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch)
|
||||||
func doMergeStyleFastForwardOnly(ctx *mergeContext) error {
|
func doMergeStyleFastForwardOnly(ctx *mergeContext) error {
|
||||||
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(trackingBranch)
|
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||||
if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil {
|
if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil {
|
||||||
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch)
|
// doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch)
|
||||||
func doMergeStyleMerge(ctx *mergeContext, message string) error {
|
func doMergeStyleMerge(ctx *mergeContext, message string) error {
|
||||||
cmd := gitcmd.NewCommand("merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch)
|
cmd := gitcmd.NewCommand("merge", "--no-ff", "--no-commit").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||||
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
|
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
|
||||||
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package pull
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
|
|||||||
|
|
||||||
if expectedHeadCommitID != "" {
|
if expectedHeadCommitID != "" {
|
||||||
trackingCommitID, _, err := gitcmd.NewCommand("show-ref", "--hash").
|
trackingCommitID, _, err := gitcmd.NewCommand("show-ref", "--hash").
|
||||||
AddDynamicArguments(git.BranchPrefix + trackingBranch).
|
AddDynamicArguments(git.BranchPrefix + tmpRepoTrackingBranch).
|
||||||
WithEnv(mergeCtx.env).
|
WithEnv(mergeCtx.env).
|
||||||
WithDir(mergeCtx.tmpBasePath).
|
WithDir(mergeCtx.tmpBasePath).
|
||||||
RunStdString(ctx)
|
RunStdString(ctx)
|
||||||
@@ -152,8 +152,8 @@ func prepareTemporaryRepoForMerge(ctx *mergeContext) error {
|
|||||||
}
|
}
|
||||||
defer sparseCheckoutListFile.Close() // we will close it earlier but we need to ensure it is closed if there is an error
|
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 {
|
if err := getDiffTree(ctx, ctx.tmpBasePath, tmpRepoBaseBranch, tmpRepoTrackingBranch, sparseCheckoutListFile); err != nil {
|
||||||
log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, baseBranch, trackingBranch, err)
|
log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, tmpRepoBaseBranch, tmpRepoTrackingBranch, err)
|
||||||
return fmt.Errorf("getDiffTree: %w", err)
|
return fmt.Errorf("getDiffTree: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,19 +206,6 @@ func prepareTemporaryRepoForMerge(ctx *mergeContext) error {
|
|||||||
// getDiffTree returns a string containing all the files that were changed between headBranch and baseBranch
|
// 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
|
// 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 {
|
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")
|
cmd := gitcmd.NewCommand("diff-tree", "--no-commit-id", "--name-only", "-r", "-r", "-z", "--root")
|
||||||
diffOutReader, diffOutReaderClose := cmd.MakeStdoutPipe()
|
diffOutReader, diffOutReaderClose := cmd.MakeStdoutPipe()
|
||||||
defer diffOutReaderClose()
|
defer diffOutReaderClose()
|
||||||
@@ -227,7 +214,7 @@ func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, o
|
|||||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||||
// Now scan the output from the command
|
// Now scan the output from the command
|
||||||
scanner := bufio.NewScanner(diffOutReader)
|
scanner := bufio.NewScanner(diffOutReader)
|
||||||
scanner.Split(scanNullTerminatedStrings)
|
scanner.Split(util.BufioScannerSplit(0))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
treePath := scanner.Text()
|
treePath := scanner.Text()
|
||||||
// escape '*', '?', '[', spaces and '!' prefix
|
// escape '*', '?', '[', spaces and '!' prefix
|
||||||
@@ -266,14 +253,14 @@ func (err ErrRebaseConflicts) Error() string {
|
|||||||
// if there is a conflict it will return an ErrRebaseConflicts
|
// if there is a conflict it will return an ErrRebaseConflicts
|
||||||
func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error {
|
func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error {
|
||||||
// Checkout head branch
|
// Checkout head branch
|
||||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout", "-b").AddDynamicArguments(stagingBranch, trackingBranch)).
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout", "-b").AddDynamicArguments(tmpRepoStagingBranch, tmpRepoTrackingBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
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())
|
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()
|
ctx.outbuf.Reset()
|
||||||
|
|
||||||
// Rebase before merging
|
// Rebase before merging
|
||||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("rebase").AddDynamicArguments(baseBranch)).
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("rebase").AddDynamicArguments(tmpRepoBaseBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
RunWithStderr(ctx); err != nil {
|
||||||
// Rebase will leave a REBASE_HEAD file in .git if there is a conflict
|
// 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 {
|
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func doMergeRebaseFastForward(ctx *mergeContext) error {
|
|||||||
return fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
return fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(stagingBranch)
|
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(tmpRepoStagingBranch)
|
||||||
if err := runMergeCommand(ctx, repo_model.MergeStyleRebase, cmd); err != nil {
|
if err := runMergeCommand(ctx, repo_model.MergeStyleRebase, cmd); err != nil {
|
||||||
log.Error("Unable to merge staging into base: %v", err)
|
log.Error("Unable to merge staging into base: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -88,7 +88,7 @@ func doMergeRebaseFastForward(ctx *mergeContext) error {
|
|||||||
|
|
||||||
// Perform rebase merge with merge commit.
|
// Perform rebase merge with merge commit.
|
||||||
func doMergeRebaseMergeCommit(ctx *mergeContext, message string) error {
|
func doMergeRebaseMergeCommit(ctx *mergeContext, message string) error {
|
||||||
cmd := gitcmd.NewCommand("merge").AddArguments("--no-ff", "--no-commit").AddDynamicArguments(stagingBranch)
|
cmd := gitcmd.NewCommand("merge").AddArguments("--no-ff", "--no-commit").AddDynamicArguments(tmpRepoStagingBranch)
|
||||||
|
|
||||||
if err := runMergeCommand(ctx, repo_model.MergeStyleRebaseMerge, cmd); err != nil {
|
if err := runMergeCommand(ctx, repo_model.MergeStyleRebaseMerge, cmd); err != nil {
|
||||||
log.Error("Unable to merge staging into base: %v", err)
|
log.Error("Unable to merge staging into base: %v", err)
|
||||||
@@ -109,7 +109,7 @@ func doMergeStyleRebase(ctx *mergeContext, mergeStyle repo_model.MergeStyle, mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checkout base branch again
|
// Checkout base branch again
|
||||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout").AddDynamicArguments(baseBranch)).
|
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout").AddDynamicArguments(tmpRepoBaseBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
RunWithStderr(ctx); err != nil {
|
||||||
log.Error("git checkout base prior to merge post staging rebase %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
log.Error("git checkout base prior to merge post staging rebase %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||||
return fmt.Errorf("git checkout base prior to merge post staging rebase %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
return fmt.Errorf("git checkout base prior to merge post staging rebase %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ func getAuthorSignatureSquash(ctx *mergeContext) (*git.Signature, error) {
|
|||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
commits, err := gitRepo.CommitsBetweenIDs(trackingBranch, "HEAD")
|
commits, err := gitRepo.CommitsBetweenIDs(tmpRepoTrackingBranch, "HEAD")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("%-v Unable to get commits between: %s %s: %v", ctx.pr, "HEAD", trackingBranch, err)
|
log.Error("%-v Unable to get commits between: %s %s: %v", ctx.pr, "HEAD", tmpRepoTrackingBranch, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func doMergeStyleSquash(ctx *mergeContext, message string) error {
|
|||||||
return fmt.Errorf("getAuthorSignatureSquash: %w", err)
|
return fmt.Errorf("getAuthorSignatureSquash: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdMerge := gitcmd.NewCommand("merge", "--squash").AddDynamicArguments(trackingBranch)
|
cmdMerge := gitcmd.NewCommand("merge", "--squash").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||||
if err := runMergeCommand(ctx, repo_model.MergeStyleSquash, cmdMerge); err != nil {
|
if err := runMergeCommand(ctx, repo_model.MergeStyleSquash, cmdMerge); err != nil {
|
||||||
log.Error("%-v Unable to merge --squash tracking into base: %v", ctx.pr, err)
|
log.Error("%-v Unable to merge --squash tracking into base: %v", ctx.pr, err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pull
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkConflictsMergeTree uses git merge-tree to check for conflicts and if none are found checks if the patch is empty
|
||||||
|
// return true if there are conflicts otherwise return false
|
||||||
|
// pr.Status and pr.ConflictedFiles will be updated as necessary
|
||||||
|
func checkConflictsMergeTree(ctx context.Context, pr *issues_model.PullRequest, baseCommitID string) (bool, error) {
|
||||||
|
treeHash, conflict, conflictFiles, err := gitrepo.MergeTree(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID, pr.MergeBase)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("MergeTree: %w", err)
|
||||||
|
}
|
||||||
|
if conflict {
|
||||||
|
pr.Status = issues_model.PullRequestStatusConflict
|
||||||
|
// sometimes git merge-tree will detect conflicts but not list any conflicted files
|
||||||
|
// so that pr.ConflictedFiles will be empty
|
||||||
|
pr.ConflictedFiles = conflictFiles
|
||||||
|
|
||||||
|
log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect whether the pull request introduces changes by comparing the merged tree (treeHash)
|
||||||
|
// against the current base commit (baseCommitID) using `git diff-tree`. The command returns exit code 0
|
||||||
|
// if there is no diff between these trees (empty patch) and exit code 1 if there is a diff.
|
||||||
|
gitErr := gitrepo.RunCmd(ctx, pr.BaseRepo, gitcmd.NewCommand("diff-tree", "-r", "--quiet").
|
||||||
|
AddDynamicArguments(treeHash, baseCommitID))
|
||||||
|
switch {
|
||||||
|
case gitErr == nil:
|
||||||
|
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
|
||||||
|
pr.Status = issues_model.PullRequestStatusEmpty
|
||||||
|
case gitcmd.IsErrorExitCode(gitErr, 1):
|
||||||
|
pr.Status = issues_model.PullRequestStatusMergeable
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("run diff-tree exit abnormally: %w", gitErr)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPullRequestMergeableByMergeTree(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||||
|
// 1. Get head commit
|
||||||
|
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OpenRepository: %w", err)
|
||||||
|
}
|
||||||
|
defer headGitRepo.Close()
|
||||||
|
|
||||||
|
// 2. Get/open base repository
|
||||||
|
var baseGitRepo *git.Repository
|
||||||
|
if pr.IsSameRepo() {
|
||||||
|
baseGitRepo = headGitRepo
|
||||||
|
} else {
|
||||||
|
baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OpenRepository: %w", err)
|
||||||
|
}
|
||||||
|
defer baseGitRepo.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get head commit id
|
||||||
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||||
|
pr.HeadCommitID, err = headGitRepo.GetRefCommitID(git.BranchPrefix + pr.HeadBranch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if pr.ID > 0 {
|
||||||
|
pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetRefCommitID: can't find commit ID for head: %w", err)
|
||||||
|
}
|
||||||
|
} else if pr.HeadCommitID == "" { // for new pull request with agit, the head commit id must be provided
|
||||||
|
return errors.New("head commit ID is empty for pull request Agit flow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. fetch head commit id into the current repository
|
||||||
|
// it will be checked in 2 weeks by default from git if the pull request created failure.
|
||||||
|
if !pr.IsSameRepo() {
|
||||||
|
if !baseGitRepo.IsReferenceExist(pr.HeadCommitID) {
|
||||||
|
if err := gitrepo.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, pr.HeadCommitID); err != nil {
|
||||||
|
return fmt.Errorf("FetchRemoteCommit: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. update merge base
|
||||||
|
baseCommitID, err := baseGitRepo.GetRefCommitID(git.BranchPrefix + pr.BaseBranch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetBranchCommitID: can't find commit ID for base: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pr.MergeBase, err = gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID)
|
||||||
|
if err != nil {
|
||||||
|
// if there is no merge base, then it's empty, still need to allow the pull request to be created
|
||||||
|
// not quite right (e.g.: why not reset the fields like below), but no interest to do more investigation at the moment
|
||||||
|
log.Error("MergeBase: unable to find merge base between %s and %s: %v", baseCommitID, pr.HeadCommitID, err)
|
||||||
|
pr.Status = issues_model.PullRequestStatusEmpty
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset conflicted files and changed protected files
|
||||||
|
pr.ConflictedFiles = nil
|
||||||
|
pr.ChangedProtectedFiles = nil
|
||||||
|
|
||||||
|
// 6. if base == head, then it's an ancestor
|
||||||
|
if pr.HeadCommitID == pr.MergeBase {
|
||||||
|
pr.Status = issues_model.PullRequestStatusAncestor
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Check for conflicts
|
||||||
|
conflicted, err := checkConflictsMergeTree(ctx, pr, baseCommitID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("checkConflictsMergeTree: %v", err)
|
||||||
|
pr.Status = issues_model.PullRequestStatusError
|
||||||
|
return fmt.Errorf("checkConflictsMergeTree: %w", err)
|
||||||
|
}
|
||||||
|
if conflicted || pr.Status == issues_model.PullRequestStatusEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Check for protected files changes
|
||||||
|
if err = checkPullFilesProtection(ctx, pr, baseGitRepo, pr.HeadCommitID); err != nil {
|
||||||
|
return fmt.Errorf("checkPullFilesProtection: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pull
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPullRequestMergeCheck(t *testing.T,
|
||||||
|
targetFunc func(ctx context.Context, pr *issues_model.PullRequest) error,
|
||||||
|
pr *issues_model.PullRequest,
|
||||||
|
expectedStatus issues_model.PullRequestStatus,
|
||||||
|
expectedConflictedFiles []string,
|
||||||
|
expectedChangedProtectedFiles []string,
|
||||||
|
) {
|
||||||
|
assert.NoError(t, pr.LoadIssue(t.Context()))
|
||||||
|
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||||
|
assert.NoError(t, pr.LoadHeadRepo(t.Context()))
|
||||||
|
pr.Status = issues_model.PullRequestStatusChecking
|
||||||
|
pr.ConflictedFiles = []string{"unrelated-conflicted-file"}
|
||||||
|
pr.ChangedProtectedFiles = []string{"unrelated-protected-file"}
|
||||||
|
pr.MergeBase = ""
|
||||||
|
pr.HeadCommitID = ""
|
||||||
|
err := targetFunc(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedStatus, pr.Status)
|
||||||
|
assert.Equal(t, expectedConflictedFiles, pr.ConflictedFiles)
|
||||||
|
assert.Equal(t, expectedChangedProtectedFiles, pr.ChangedProtectedFiles)
|
||||||
|
assert.NotEmpty(t, pr.MergeBase)
|
||||||
|
assert.NotEmpty(t, pr.HeadCommitID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullRequestMergeable(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||||
|
t.Run("NoConflict-MergeTree", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusMergeable, nil, nil)
|
||||||
|
})
|
||||||
|
t.Run("NoConflict-TmpRepo", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusMergeable, nil, nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
pr.BaseBranch, pr.HeadBranch = "test-merge-tree-conflict-base", "test-merge-tree-conflict-head"
|
||||||
|
conflictFiles := createConflictBranches(t, pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.HeadBranch)
|
||||||
|
t.Run("Conflict-MergeTree", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusConflict, conflictFiles, nil)
|
||||||
|
})
|
||||||
|
t.Run("Conflict-TmpRepo", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusConflict, conflictFiles, nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
pr.BaseBranch, pr.HeadBranch = "test-merge-tree-empty-base", "test-merge-tree-empty-head"
|
||||||
|
createEmptyBranches(t, pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.HeadBranch)
|
||||||
|
t.Run("Empty-MergeTree", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusEmpty, nil, nil)
|
||||||
|
})
|
||||||
|
t.Run("Empty-TmpRepo", func(t *testing.T) {
|
||||||
|
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusEmpty, nil, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createConflictBranches(t *testing.T, repoPath, baseBranch, headBranch string) []string {
|
||||||
|
conflictFile := "conflict.txt"
|
||||||
|
stdin := fmt.Sprintf(
|
||||||
|
`reset refs/heads/%[1]s
|
||||||
|
from refs/heads/master
|
||||||
|
|
||||||
|
commit refs/heads/%[1]s
|
||||||
|
mark :1
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 17
|
||||||
|
add conflict file
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 4
|
||||||
|
base
|
||||||
|
|
||||||
|
commit refs/heads/%[1]s
|
||||||
|
mark :2
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 11
|
||||||
|
base change
|
||||||
|
from :1
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 11
|
||||||
|
base change
|
||||||
|
|
||||||
|
reset refs/heads/%[2]s
|
||||||
|
from :1
|
||||||
|
|
||||||
|
commit refs/heads/%[2]s
|
||||||
|
mark :3
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 11
|
||||||
|
head change
|
||||||
|
from :1
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 11
|
||||||
|
head change
|
||||||
|
`, baseBranch, headBranch, conflictFile)
|
||||||
|
err := gitcmd.NewCommand("fast-import").WithDir(repoPath).WithStdinBytes([]byte(stdin)).RunWithStderr(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
return []string{conflictFile}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEmptyBranches(t *testing.T, repoPath, baseBranch, headBranch string) {
|
||||||
|
emptyFile := "empty.txt"
|
||||||
|
stdin := fmt.Sprintf(`reset refs/heads/%[1]s
|
||||||
|
from refs/heads/master
|
||||||
|
|
||||||
|
commit refs/heads/%[1]s
|
||||||
|
mark :1
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 14
|
||||||
|
add empty file
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 4
|
||||||
|
base
|
||||||
|
|
||||||
|
reset refs/heads/%[2]s
|
||||||
|
from :1
|
||||||
|
|
||||||
|
commit refs/heads/%[2]s
|
||||||
|
mark :2
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 17
|
||||||
|
change empty file
|
||||||
|
from :1
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 6
|
||||||
|
change
|
||||||
|
|
||||||
|
commit refs/heads/%[2]s
|
||||||
|
mark :3
|
||||||
|
committer Test <test@example.com> 0 +0000
|
||||||
|
data 17
|
||||||
|
revert empty file
|
||||||
|
from :2
|
||||||
|
M 100644 inline %[3]s
|
||||||
|
data 4
|
||||||
|
base
|
||||||
|
`, baseBranch, headBranch, emptyFile)
|
||||||
|
err := gitcmd.NewCommand("fast-import").WithDir(repoPath).WithStdinBytes([]byte(stdin)).RunWithStderr(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
+35
-27
@@ -21,7 +21,6 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/glob"
|
"code.gitea.io/gitea/modules/glob"
|
||||||
"code.gitea.io/gitea/modules/graceful"
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/process"
|
"code.gitea.io/gitea/modules/process"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
@@ -67,10 +66,18 @@ var patchErrorSuffices = []string{
|
|||||||
": does not exist in index",
|
": does not exist in index",
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error {
|
func checkPullRequestBranchMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||||
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("testPullRequestBranchMergeable: %s", pr))
|
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("checkPullRequestBranchMergeable: %s", pr))
|
||||||
defer finished()
|
defer finished()
|
||||||
|
|
||||||
|
if git.DefaultFeatures().SupportGitMergeTree {
|
||||||
|
return checkPullRequestMergeableByMergeTree(ctx, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPullRequestMergeableByTmpRepo(ctx, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPullRequestMergeableByTmpRepo(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||||
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !git_model.IsErrBranchNotExist(err) {
|
if !git_model.IsErrBranchNotExist(err) {
|
||||||
@@ -80,10 +87,6 @@ func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error {
|
|||||||
}
|
}
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
return testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPullRequestTmpRepoBranchMergeable(ctx context.Context, prCtx *prTmpRepoContext, pr *issues_model.PullRequest) error {
|
|
||||||
gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
|
gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("OpenRepository: %w", err)
|
return fmt.Errorf("OpenRepository: %w", err)
|
||||||
@@ -91,16 +94,16 @@ func testPullRequestTmpRepoBranchMergeable(ctx context.Context, prCtx *prTmpRepo
|
|||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
// 1. update merge base
|
// 1. update merge base
|
||||||
pr.MergeBase, _, err = gitcmd.NewCommand("merge-base", "--", "base", "tracking").WithDir(prCtx.tmpBasePath).RunStdString(ctx)
|
pr.MergeBase, _, err = gitcmd.NewCommand("merge-base", "--", tmpRepoBaseBranch, tmpRepoTrackingBranch).WithDir(prCtx.tmpBasePath).RunStdString(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var err2 error
|
var err2 error
|
||||||
pr.MergeBase, err2 = gitRepo.GetRefCommitID(git.BranchPrefix + "base")
|
pr.MergeBase, err2 = gitRepo.GetRefCommitID(git.BranchPrefix + tmpRepoBaseBranch)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return fmt.Errorf("GetMergeBase: %v and can't find commit ID for base: %w", err, err2)
|
return fmt.Errorf("GetMergeBase: %v and can't find commit ID for base: %w", err, err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pr.MergeBase = strings.TrimSpace(pr.MergeBase)
|
pr.MergeBase = strings.TrimSpace(pr.MergeBase)
|
||||||
if pr.HeadCommitID, err = gitRepo.GetRefCommitID(git.BranchPrefix + "tracking"); err != nil {
|
if pr.HeadCommitID, err = gitRepo.GetRefCommitID(git.BranchPrefix + tmpRepoTrackingBranch); err != nil {
|
||||||
return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err)
|
return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,17 +113,19 @@ func testPullRequestTmpRepoBranchMergeable(ctx context.Context, prCtx *prTmpRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check for conflicts
|
// 2. Check for conflicts
|
||||||
if conflicts, err := checkConflicts(ctx, pr, gitRepo, prCtx.tmpBasePath); err != nil || conflicts || pr.Status == issues_model.PullRequestStatusEmpty {
|
conflicts, err := checkConflictsByTmpRepo(ctx, pr, gitRepo, prCtx.tmpBasePath)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check for protected files changes
|
pr.ChangedProtectedFiles = nil
|
||||||
if err = checkPullFilesProtection(ctx, pr, gitRepo); err != nil {
|
if conflicts || pr.Status == issues_model.PullRequestStatusEmpty {
|
||||||
return fmt.Errorf("pr.CheckPullFilesProtection(): %v", err)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(pr.ChangedProtectedFiles) > 0 {
|
// 3. Check for protected files changes
|
||||||
log.Trace("Found %d protected files changed", len(pr.ChangedProtectedFiles))
|
if err = checkPullFilesProtection(ctx, pr, gitRepo, tmpRepoTrackingBranch); err != nil {
|
||||||
|
return fmt.Errorf("pr.CheckPullFilesProtection(): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pr.Status = issues_model.PullRequestStatusMergeable
|
pr.Status = issues_model.PullRequestStatusMergeable
|
||||||
@@ -307,14 +312,14 @@ func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repo
|
|||||||
return conflict, conflictedFiles, nil
|
return conflict, conflictedFiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) {
|
func checkConflictsByTmpRepo(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) {
|
||||||
// 1. checkConflicts resets the conflict status - therefore - reset the conflict status
|
// 1. checkConflictsByTmpRepo resets the conflict status - therefore - reset the conflict status
|
||||||
pr.ConflictedFiles = nil
|
pr.ConflictedFiles = nil
|
||||||
|
|
||||||
// 2. AttemptThreeWayMerge first - this is much quicker than plain patch to base
|
// 2. AttemptThreeWayMerge first - this is much quicker than plain patch to base
|
||||||
description := fmt.Sprintf("PR[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index)
|
description := fmt.Sprintf("PR[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index)
|
||||||
conflict, conflictFiles, err := AttemptThreeWayMerge(ctx,
|
conflict, conflictFiles, err := AttemptThreeWayMerge(ctx,
|
||||||
tmpBasePath, gitRepo, pr.MergeBase, "base", "tracking", description)
|
tmpBasePath, gitRepo, pr.MergeBase, tmpRepoBaseBranch, tmpRepoTrackingBranch, description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -329,7 +334,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
|||||||
return false, fmt.Errorf("unable to write unconflicted tree: %w\n`git ls-files -u`:\n%s", err, lsfiles)
|
return false, fmt.Errorf("unable to write unconflicted tree: %w\n`git ls-files -u`:\n%s", err, lsfiles)
|
||||||
}
|
}
|
||||||
treeHash = strings.TrimSpace(treeHash)
|
treeHash = strings.TrimSpace(treeHash)
|
||||||
baseTree, err := gitRepo.GetTree("base")
|
baseTree, err := gitRepo.GetTree(tmpRepoBaseBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -379,10 +384,10 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace("PullRequest[%d].testPullRequestTmpRepoBranchMergeable (patchPath): %s", pr.ID, patchPath)
|
log.Trace("PullRequest[%d].checkPullRequestMergeableByTmpRepo (patchPath): %s", pr.ID, patchPath)
|
||||||
|
|
||||||
// 4. Read the base branch in to the index of the temporary repository
|
// 4. Read the base branch in to the index of the temporary repository
|
||||||
_, _, err = gitcmd.NewCommand("read-tree", "base").WithDir(tmpBasePath).RunStdString(ctx)
|
_, _, err = gitcmd.NewCommand("read-tree", tmpRepoBaseBranch).WithDir(tmpBasePath).RunStdString(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("git read-tree %s: %w", pr.BaseBranch, err)
|
return false, fmt.Errorf("git read-tree %s: %w", pr.BaseBranch, err)
|
||||||
}
|
}
|
||||||
@@ -434,7 +439,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
|||||||
scanner := bufio.NewScanner(stderrReader)
|
scanner := bufio.NewScanner(stderrReader)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
log.Trace("PullRequest[%d].testPullRequestTmpRepoBranchMergeable: stderr: %s", pr.ID, line)
|
log.Trace("PullRequest[%d].checkPullRequestMergeableByTmpRepo: stderr: %s", pr.ID, line)
|
||||||
if strings.HasPrefix(line, prefix) {
|
if strings.HasPrefix(line, prefix) {
|
||||||
conflict = true
|
conflict = true
|
||||||
filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0])
|
filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0])
|
||||||
@@ -459,8 +464,8 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
|||||||
conflicts.Add(filepath)
|
conflicts.Add(filepath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// only list 10 conflicted files
|
// only list part of conflicted files
|
||||||
if len(conflicts) >= 10 {
|
if len(conflicts) >= gitrepo.MaxConflictedDetectFiles {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,7 +575,7 @@ func CheckUnprotectedFiles(repo *git.Repository, branchName, oldCommitID, newCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkPullFilesProtection check if pr changed protected files and save results
|
// checkPullFilesProtection check if pr changed protected files and save results
|
||||||
func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) error {
|
func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, headRef string) error {
|
||||||
if pr.Status == issues_model.PullRequestStatusEmpty {
|
if pr.Status == issues_model.PullRequestStatusEmpty {
|
||||||
pr.ChangedProtectedFiles = nil
|
pr.ChangedProtectedFiles = nil
|
||||||
return nil
|
return nil
|
||||||
@@ -586,9 +591,12 @@ func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pr.ChangedProtectedFiles, err = CheckFileProtection(gitRepo, pr.HeadBranch, pr.MergeBase, "tracking", pb.GetProtectedFilePatterns(), 10, os.Environ())
|
pr.ChangedProtectedFiles, err = CheckFileProtection(gitRepo, pr.HeadBranch, pr.MergeBase, headRef, pb.GetProtectedFilePatterns(), 10, os.Environ())
|
||||||
if err != nil && !IsErrFilePathProtected(err) {
|
if err != nil && !IsErrFilePathProtected(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(pr.ChangedProtectedFiles) > 0 {
|
||||||
|
log.Trace("Found %d protected files changed in PR %s#%d", len(pr.ChangedProtectedFiles), pr.BaseRepo.FullName(), pr.Index)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func readUnmergedLsFileLines(ctx context.Context, tmpBasePath string, outputChan
|
|||||||
lsFilesReader, lsFilesReaderClose := cmd.MakeStdoutPipe()
|
lsFilesReader, lsFilesReaderClose := cmd.MakeStdoutPipe()
|
||||||
defer lsFilesReaderClose()
|
defer lsFilesReaderClose()
|
||||||
err := cmd.WithDir(tmpBasePath).
|
err := cmd.WithDir(tmpBasePath).
|
||||||
WithPipelineFunc(func(_ gitcmd.Context) error {
|
WithPipelineFunc(func(gitcmd.Context) error {
|
||||||
bufferedReader := bufio.NewReader(lsFilesReader)
|
bufferedReader := bufio.NewReader(lsFilesReader)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
+6
-11
@@ -90,16 +90,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
||||||
if err != nil {
|
|
||||||
if !git_model.IsErrBranchNotExist(err) {
|
|
||||||
log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +119,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
|||||||
pr.Issue = issue
|
pr.Issue = issue
|
||||||
issue.PullRequest = pr
|
issue.PullRequest = pr
|
||||||
|
|
||||||
|
var err error
|
||||||
if pr.Flow == issues_model.PullRequestFlowGithub {
|
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||||
err = PushToBaseRepo(ctx, pr)
|
err = PushToBaseRepo(ctx, pr)
|
||||||
} else {
|
} else {
|
||||||
@@ -168,6 +160,9 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
|||||||
// Request reviews, these should be requested before other notifications because they will add request reviews record
|
// Request reviews, these should be requested before other notifications because they will add request reviews record
|
||||||
// on database
|
// on database
|
||||||
permDoer, err := access_model.GetUserRepoPermission(ctx, repo, issue.Poster)
|
permDoer, err := access_model.GetUserRepoPermission(ctx, repo, issue.Poster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, reviewer := range opts.Reviewers {
|
for _, reviewer := range opts.Reviewers {
|
||||||
if _, err = issue_service.ReviewRequest(ctx, pr.Issue, issue.Poster, &permDoer, reviewer, true); err != nil {
|
if _, err = issue_service.ReviewRequest(ctx, pr.Issue, issue.Poster, &permDoer, reviewer, true); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -301,7 +296,7 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer
|
|||||||
pr.BaseBranch = targetBranch
|
pr.BaseBranch = targetBranch
|
||||||
|
|
||||||
// Refresh patch
|
// Refresh patch
|
||||||
if err := testPullRequestBranchMergeable(pr); err != nil {
|
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import (
|
|||||||
// Temporary repos created here use standard branch names to help simplify
|
// Temporary repos created here use standard branch names to help simplify
|
||||||
// merging code
|
// merging code
|
||||||
const (
|
const (
|
||||||
baseBranch = "base" // equivalent to pr.BaseBranch
|
tmpRepoBaseBranch = "base" // equivalent to pr.BaseBranch
|
||||||
trackingBranch = "tracking" // equivalent to pr.HeadBranch
|
tmpRepoTrackingBranch = "tracking" // equivalent to pr.HeadBranch
|
||||||
stagingBranch = "staging" // this is used for a working branch
|
tmpRepoStagingBranch = "staging" // this is used for a working branch
|
||||||
)
|
)
|
||||||
|
|
||||||
type prTmpRepoContext struct {
|
type prTmpRepoContext struct {
|
||||||
@@ -95,7 +95,6 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
remoteRepoName := "head_repo"
|
remoteRepoName := "head_repo"
|
||||||
baseBranch := "base"
|
|
||||||
|
|
||||||
fetchArgs := gitcmd.TrustedCmdArgs{"--no-tags"}
|
fetchArgs := gitcmd.TrustedCmdArgs{"--no-tags"}
|
||||||
if git.DefaultFeatures().CheckVersionAtLeast("2.25.0") {
|
if git.DefaultFeatures().CheckVersionAtLeast("2.25.0") {
|
||||||
@@ -135,14 +134,14 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch", "origin").AddArguments(fetchArgs...).
|
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch", "origin").AddArguments(fetchArgs...).
|
||||||
AddDashesAndList(git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+baseBranch, git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+"original_"+baseBranch)).
|
AddDashesAndList(git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+tmpRepoBaseBranch, git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+"original_"+tmpRepoBaseBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
RunWithStderr(ctx); err != nil {
|
||||||
log.Error("%-v Unable to fetch origin base branch [%s:%s -> base, original_base in %s]: %v:\n%s\n%s", pr, pr.BaseRepo.FullName(), pr.BaseBranch, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
log.Error("%-v Unable to fetch origin base branch [%s:%s -> base, original_base in %s]: %v:\n%s\n%s", pr, pr.BaseRepo.FullName(), pr.BaseBranch, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||||
cancel()
|
cancel()
|
||||||
return nil, nil, fmt.Errorf("Unable to fetch origin base branch [%s:%s -> base, original_base in tmpBasePath]: %w\n%s\n%s", pr.BaseRepo.FullName(), pr.BaseBranch, err, prCtx.outbuf.String(), err.Stderr())
|
return nil, nil, fmt.Errorf("Unable to fetch origin base branch [%s:%s -> base, original_base in tmpBasePath]: %w\n%s\n%s", pr.BaseRepo.FullName(), pr.BaseBranch, err, prCtx.outbuf.String(), err.Stderr())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("symbolic-ref").AddDynamicArguments("HEAD", git.BranchPrefix+baseBranch)).
|
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("symbolic-ref").AddDynamicArguments("HEAD", git.BranchPrefix+tmpRepoBaseBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
RunWithStderr(ctx); err != nil {
|
||||||
log.Error("%-v Unable to set HEAD as base branch in [%s]: %v\n%s\n%s", pr, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
log.Error("%-v Unable to set HEAD as base branch in [%s]: %v\n%s\n%s", pr, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||||
cancel()
|
cancel()
|
||||||
@@ -162,7 +161,6 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
|
|||||||
return nil, nil, fmt.Errorf("Unable to add head repository as head_repo [%s -> tmpBasePath]: %w\n%s\n%s", pr.HeadRepo.FullName(), err, prCtx.outbuf.String(), err.Stderr())
|
return nil, nil, fmt.Errorf("Unable to add head repository as head_repo [%s -> tmpBasePath]: %w\n%s\n%s", pr.HeadRepo.FullName(), err, prCtx.outbuf.String(), err.Stderr())
|
||||||
}
|
}
|
||||||
|
|
||||||
trackingBranch := "tracking"
|
|
||||||
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
||||||
// Fetch head branch
|
// Fetch head branch
|
||||||
var headBranch string
|
var headBranch string
|
||||||
@@ -173,7 +171,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
|
|||||||
} else {
|
} else {
|
||||||
headBranch = pr.GetGitHeadRefName()
|
headBranch = pr.GetGitHeadRefName()
|
||||||
}
|
}
|
||||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch").AddArguments(fetchArgs...).AddDynamicArguments(remoteRepoName, headBranch+":"+trackingBranch)).
|
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch").AddArguments(fetchArgs...).AddDynamicArguments(remoteRepoName, headBranch+":"+tmpRepoTrackingBranch)).
|
||||||
RunWithStderr(ctx); err != nil {
|
RunWithStderr(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
if exist, _ := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); !exist {
|
if exist, _ := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); !exist {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func updateHeadByRebaseOnToBase(ctx context.Context, pr *issues_model.PullReques
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Determine the old merge-base before the rebase - we use this for LFS push later on
|
// Determine the old merge-base before the rebase - we use this for LFS push later on
|
||||||
oldMergeBase, _, _ := gitcmd.NewCommand("merge-base").AddDashesAndList(baseBranch, trackingBranch).
|
oldMergeBase, _, _ := gitcmd.NewCommand("merge-base").AddDashesAndList(tmpRepoBaseBranch, tmpRepoTrackingBranch).
|
||||||
WithDir(mergeCtx.tmpBasePath).RunStdString(ctx)
|
WithDir(mergeCtx.tmpBasePath).RunStdString(ctx)
|
||||||
oldMergeBase = strings.TrimSpace(oldMergeBase)
|
oldMergeBase = strings.TrimSpace(oldMergeBase)
|
||||||
|
|
||||||
@@ -42,11 +42,11 @@ func updateHeadByRebaseOnToBase(ctx context.Context, pr *issues_model.PullReques
|
|||||||
// It's questionable about where this should go - either after or before the push
|
// It's questionable about where this should go - either after or before the push
|
||||||
// I think in the interests of data safety - failures to push to the lfs should prevent
|
// I think in the interests of data safety - failures to push to the lfs should prevent
|
||||||
// the push as you can always re-rebase.
|
// the push as you can always re-rebase.
|
||||||
if err := LFSPush(ctx, mergeCtx.tmpBasePath, baseBranch, oldMergeBase, &issues_model.PullRequest{
|
if err := LFSPush(ctx, mergeCtx.tmpBasePath, tmpRepoBaseBranch, oldMergeBase, &issues_model.PullRequest{
|
||||||
HeadRepoID: pr.BaseRepoID,
|
HeadRepoID: pr.BaseRepoID,
|
||||||
BaseRepoID: pr.HeadRepoID,
|
BaseRepoID: pr.HeadRepoID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("Unable to push lfs objects between %s and %s up to head branch in %-v: %v", baseBranch, oldMergeBase, pr, err)
|
log.Error("Unable to push lfs objects between %s and %s up to head branch in %-v: %v", tmpRepoBaseBranch, oldMergeBase, pr, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func updateHeadByRebaseOnToBase(ctx context.Context, pr *issues_model.PullReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
pushCmd := gitcmd.NewCommand("push", "-f", "head_repo").
|
pushCmd := gitcmd.NewCommand("push", "-f", "head_repo").
|
||||||
AddDynamicArguments(stagingBranch + ":" + git.BranchPrefix + pr.HeadBranch)
|
AddDynamicArguments(tmpRepoStagingBranch + ":" + git.BranchPrefix + pr.HeadBranch)
|
||||||
|
|
||||||
// Push back to the head repository.
|
// Push back to the head repository.
|
||||||
// TODO: this cause an api call to "/api/internal/hook/post-receive/...",
|
// TODO: this cause an api call to "/api/internal/hook/post-receive/...",
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bo
|
|||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
if bytes.IndexByte(line, '*') >= 0 {
|
if bytes.IndexByte(line, '*') >= 0 {
|
||||||
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||||
return ctx.CancelWithCause(err)
|
return ctx.CancelPipeline(err)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bo
|
|||||||
row++
|
row++
|
||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||||
return ctx.CancelWithCause(err)
|
return ctx.CancelPipeline(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return scanner.Err()
|
return scanner.Err()
|
||||||
|
|||||||
@@ -82,6 +82,8 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{{range .ConflictedFiles}}
|
{{range .ConflictedFiles}}
|
||||||
<li>{{.}}</li>
|
<li>{{.}}</li>
|
||||||
|
{{else}}
|
||||||
|
<li>{{ctx.Locale.Tr "repo.pulls.files_conflicted_no_listed_files"}}</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else if .IsPullRequestBroken}}
|
{{else if .IsPullRequestBroken}}
|
||||||
|
|||||||
Reference in New Issue
Block a user