Files
Gitea/services/pull/merge_tree.go
Lunny Xiao 1463426a27 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>
2026-01-27 11:57:20 -08:00

145 lines
5.1 KiB
Go

// 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
}