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:
Lunny Xiao
2026-01-27 11:57:20 -08:00
committed by GitHub
parent 125257eacf
commit 1463426a27
29 changed files with 607 additions and 126 deletions
+2 -1
View File
@@ -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.
+2
View File
@@ -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
} }
+37 -7
View File
@@ -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)
} }
+9 -5
View File
@@ -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
} }
+5 -5
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+6 -14
View File
@@ -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)
} }
+59
View File
@@ -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)
}
+82
View File
@@ -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)
}
+22
View File
@@ -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
}
}
+1
View File
@@ -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.",
+2 -2
View File
@@ -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
}). }).
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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").
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7 -20
View File
@@ -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 {
+3 -3
View File
@@ -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())
+3 -3
View File
@@ -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
+144
View File
@@ -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
}
+154
View File
@@ -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
View File
@@ -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
} }
+1 -1
View File
@@ -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
View File
@@ -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
} }
+6 -8
View File
@@ -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 {
+4 -4
View File
@@ -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/...",
+2 -2
View File
@@ -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}}