Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dda296a
Fix lint
lunny Sep 27, 2025
21cc4aa
improvements
lunny Sep 27, 2025
2f74aec
remove unused functions
lunny Sep 27, 2025
3d0222c
allow empty pull request
lunny Sep 27, 2025
7e973b3
improvements
lunny Sep 28, 2025
72a154a
fix bug
lunny Sep 29, 2025
76da4bf
fix bug
lunny Sep 29, 2025
4d4f3ae
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Sep 29, 2025
af79992
add tests for mergeable tmprepo checking
lunny Oct 4, 2025
ed5a749
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 4, 2025
e8636b7
Add both mergetree and tmprepo for rebase and retarget tests
lunny Oct 4, 2025
4b8c047
make test happy
lunny Oct 6, 2025
9fad9fb
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 6, 2025
6e96a4a
remove unnecessary check
lunny Oct 6, 2025
22f0aa2
Fix test
lunny Oct 7, 2025
793cbf7
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 7, 2025
5b1229e
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 8, 2025
07f6a8b
improvements
lunny Oct 9, 2025
0a9eff3
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 9, 2025
acb99d4
remove unused comment
lunny Oct 9, 2025
dc0abc4
Fix test
lunny Oct 10, 2025
703a0c5
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 10, 2025
b61a5ae
remove unnecessary code
lunny Oct 10, 2025
146e816
Fix test
lunny Oct 10, 2025
09f519e
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 10, 2025
d34e640
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 14, 2025
d68ad1b
improvements
lunny Oct 15, 2025
9318bbe
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 15, 2025
0d28912
Merge branch 'lunny/merge_tree_conflict_check' of github.com:lunny/gi…
lunny Oct 15, 2025
da5fb5c
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 18, 2025
59ea28c
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 21, 2025
2784879
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 22, 2025
3845774
improvements
lunny Oct 22, 2025
2609edb
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 25, 2025
562ab41
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 26, 2025
053817a
Fix test
lunny Oct 26, 2025
0bf39c7
Merge branch 'lunny/merge_tree_conflict_check' of github.com:lunny/gi…
lunny Oct 26, 2025
556ad16
Merge branch 'main' into lunny/merge_tree_conflict_check
lunny Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions modules/git/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package git

import (
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -339,18 +338,3 @@ func TestGetCommitFileStatusMerges(t *testing.T) {
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
}

func Test_GetCommitBranchStart(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := OpenRepository(t.Context(), bareRepo1Path)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetBranchCommit("branch1")
assert.NoError(t, err)
assert.Equal(t, "2839944139e0de9737a044f78b0e4b40d989a9e3", commit.ID.String())

startCommitID, err := repo.GetCommitBranchStart(os.Environ(), "branch1", commit.ID.String())
assert.NoError(t, err)
assert.NotEmpty(t, startCommitID)
assert.Equal(t, "95bb4d39648ee7e325106df01a621c530863a653", startCommitID)
}
27 changes: 13 additions & 14 deletions modules/git/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -289,20 +290,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
}

// GetAffectedFiles returns the affected files between two commits
func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID string, env []string) ([]string, error) {
if oldCommitID == emptySha1ObjectID.String() || oldCommitID == emptySha256ObjectID.String() {
startCommitID, err := repo.GetCommitBranchStart(env, branchName, newCommitID)
if err != nil {
return nil, err
}
if startCommitID == "" {
return nil, fmt.Errorf("cannot find the start commit of %s", newCommitID)
}
oldCommitID = startCommitID
func GetAffectedFiles(ctx context.Context, repoPath, oldCommitID, newCommitID string, env []string) ([]string, error) {
if oldCommitID == emptySha1ObjectID.String() {
oldCommitID = emptySha1ObjectID.Type().EmptyTree().String()
} else if oldCommitID == emptySha256ObjectID.String() {
oldCommitID = emptySha256ObjectID.Type().EmptyTree().String()
} else if oldCommitID == "" {
return nil, errors.New("oldCommitID is empty")
}

stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
log.Error("Unable to create os.Pipe for %s", repo.Path)
log.Error("Unable to create os.Pipe for %s", repoPath)
return nil, err
}
defer func() {
Expand All @@ -314,9 +313,9 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str

// Run `git diff --name-only` to get the names of the changed files
err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID).
Run(repo.Ctx, &gitcmd.RunOpts{
Run(ctx, &gitcmd.RunOpts{
Env: env,
Dir: repo.Path,
Dir: repoPath,
Stdout: stdoutWriter,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
// Close the writer end of the pipe to begin processing
Expand All @@ -338,7 +337,7 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str
},
})
if err != nil {
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repoPath, err)
}

return affectedFiles, err
Expand Down
2 changes: 2 additions & 0 deletions modules/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Features struct {
SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
SupportedObjectFormats []ObjectFormat // sha1, sha256
SupportCheckAttrOnBare bool // >= 2.40
SupportGitMergeTree bool // >= 2.38
}

var defaultFeatures *Features
Expand Down Expand Up @@ -75,6 +76,7 @@ func loadGitVersionFeatures() (*Features, error) {
features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat)
}
features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40")
features.SupportGitMergeTree = features.CheckVersionAtLeast("2.38")
return features, nil
}

Expand Down
32 changes: 0 additions & 32 deletions modules/git/repo_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,35 +534,3 @@ func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error
}
return nil
}

// GetCommitBranchStart returns the commit where the branch diverged
func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID string) (string, error) {
cmd := gitcmd.NewCommand("log", prettyLogFormat)
cmd.AddDynamicArguments(endCommitID)

stdout, _, runErr := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Env: env,
})
if runErr != nil {
return "", runErr
}

parts := bytes.SplitSeq(bytes.TrimSpace(stdout), []byte{'\n'})

// check the commits one by one until we find a commit contained by another branch
// and we think this commit is the divergence point
for commitID := range parts {
branches, err := repo.getBranches(env, string(commitID), 2)
if err != nil {
return "", err
}
for _, b := range branches {
if b != branch {
return string(commitID), nil
}
}
}

return "", nil
}
18 changes: 18 additions & 0 deletions modules/gitrepo/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitrepo

import (
"context"

"code.gitea.io/gitea/modules/git/gitcmd"
)

func FetchRemoteCommit(ctx context.Context, repo, remoteRepo Repository, commitID string) error {
_, _, err := gitcmd.NewCommand("fetch", "--no-tags").
AddDynamicArguments(repoPath(remoteRepo)).
AddDynamicArguments(commitID).
RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)})
return err
}
61 changes: 61 additions & 0 deletions modules/gitrepo/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitrepo

import (
"bytes"
"context"
"fmt"
"strings"

"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
)

func MergeBase(ctx context.Context, repo Repository, commit1, commit2 string) (string, error) {
mergeBase, _, err := gitcmd.NewCommand("merge-base", "--").
AddDynamicArguments(commit1, commit2).
RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)})
if err != nil {
return "", fmt.Errorf("get merge-base of %s and %s failed: %w", commit1, commit2, err)
}
return strings.TrimSpace(mergeBase), nil
}

func MergeTree(ctx context.Context, repo Repository, base, ours, theirs string) (string, bool, []string, error) {
cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages")
// https://git-scm.com/docs/git-merge-tree/2.40.0#_mistakes_to_avoid
if git.DefaultFeatures().CheckVersionAtLeast("2.40") && !git.DefaultFeatures().CheckVersionAtLeast("2.41") {
cmd.AddOptionFormat("--merge-base=%s", base)
}

stdout := &bytes.Buffer{}
gitErr := cmd.AddDynamicArguments(ours, theirs).Run(ctx, &gitcmd.RunOpts{
Dir: repoPath(repo),
Stdout: stdout,
})
if gitErr != nil && !gitcmd.IsErrorExitCode(gitErr, 1) {
log.Error("run merge-tree failed: %v", gitErr)
return "", false, nil, fmt.Errorf("run merge-tree failed: %w", gitErr)
}

// There are two situations that we consider for the output:
// 1. Clean merge and the output is <OID of toplevel tree>NUL
// 2. Merge conflict and the output is <OID of toplevel tree>NUL<Conflicted file info>NUL
treeOID, conflictedFileInfo, _ := strings.Cut(stdout.String(), "\x00")
if len(conflictedFileInfo) == 0 {
return treeOID, gitcmd.IsErrorExitCode(gitErr, 1), nil, nil
}

// Remove last NULL-byte from conflicted file info, then split with NULL byte as separator.
return treeOID, true, strings.Split(conflictedFileInfo[:len(conflictedFileInfo)-1], "\x00"), nil
}

func DiffTree(ctx context.Context, repo Repository, treeHash, mergeBase string) error {
return gitcmd.NewCommand("diff-tree", "--quiet").AddDynamicArguments(treeHash, mergeBase).
Run(ctx, &gitcmd.RunOpts{
Dir: repoPath(repo),
})
}
4 changes: 2 additions & 2 deletions routers/private/hook_pre_receive.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r

globs := protectBranch.GetProtectedFilePatterns()
if len(globs) > 0 {
_, err := pull_service.CheckFileProtection(gitRepo, branchName, oldCommitID, newCommitID, globs, 1, ctx.env)
_, err := pull_service.CheckFileProtection(ctx, repo.RepoPath(), oldCommitID, newCommitID, globs, 1, ctx.env)
if err != nil {
if !pull_service.IsErrFilePathProtected(err) {
log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
Expand Down Expand Up @@ -295,7 +295,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// Allow commits that only touch unprotected files
globs := protectBranch.GetUnprotectedFilePatterns()
if len(globs) > 0 {
unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(gitRepo, branchName, oldCommitID, newCommitID, globs, ctx.env)
unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(ctx, repo, oldCommitID, newCommitID, globs, ctx.env)
if err != nil {
log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Expand Down
2 changes: 1 addition & 1 deletion services/pull/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ func checkPullRequestMergeable(id int64) {
return
}

if err := testPullRequestBranchMergeable(pr); err != nil {
if err := checkPullRequestMergeableAndUpdateStatus(ctx, pr); err != nil {
log.Error("testPullRequestTmpRepoBranchMergeable[%-v]: %v", pr, err)
pr.Status = issues_model.PullRequestStatusError
if err := pr.UpdateCols(ctx, "status"); err != nil {
Expand Down
152 changes: 152 additions & 0 deletions services/pull/conflicts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2025 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"
)

// checkPullRequestMergeableAndUpdateStatus checks whether a pull request is mergeable and updates its status accordingly.
// It uses 'git merge-tree' if supported by the Git version, otherwise it falls back to using a temporary repository.
// This function updates the pr.Status, pr.MergeBase and pr.ConflictedFiles fields as necessary.
// The pull request parameter may not be created yet in the database, so do not assume it has an ID.
func checkPullRequestMergeableAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have told you, you need to clearly test this function's behavior, but not keep copy-pasting or polluting other unrelated tests

if git.DefaultFeatures().SupportGitMergeTree {
return checkPullRequestMergeableAndUpdateStatusMergeTree(ctx, pr)
}

return checkPullRequestMergeableAndUpdateStatusTmpRepo(ctx, pr)
}

// checkConflictsMergeTree uses git merge-tree to check for conflicts and if none are found checks if the patch is empty
// return true if there is 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, pr.MergeBase, baseCommitID, pr.HeadCommitID)
if err != nil {
return false, fmt.Errorf("MergeTree: %w", err)
}
if conflict {
pr.Status = issues_model.PullRequestStatusConflict
pr.ConflictedFiles = conflictFiles

log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles)
return true, nil
}

// No conflicts were detected, now check if the pull request actually
// contains anything useful via a diff. git-diff-tree(1) with --quiet
// will return exit code 0 if there's no diff and exit code 1 if there's
// a diff.
isEmpty := true
if err = gitrepo.DiffTree(ctx, pr.BaseRepo, treeHash, pr.MergeBase); err != nil {
if !gitcmd.IsErrorExitCode(err, 1) {
return false, fmt.Errorf("DiffTree: %w", err)
}
isEmpty = false
}

if isEmpty {
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
pr.Status = issues_model.PullRequestStatusEmpty
}
return false, nil
}

func checkPullRequestMergeableAndUpdateStatusMergeTree(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 base commit id
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 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 {
log.Error("GetMergeBase: %v and can't find commit ID for base: %v", err, baseCommitID)
pr.Status = issues_model.PullRequestStatusEmpty // if there is no merge base, then it's empty but we still need to allow the pull request created
return 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.PullRequestStatusEmpty // if there is no merge base, then it's empty but we still need to allow the pull request created
}
if conflicted || pr.Status == issues_model.PullRequestStatusEmpty {
return nil
}

// 7. Check for protected files changes
if err = checkPullFilesProtection(ctx, pr, pr.BaseRepo.RepoPath()); err != nil {
return fmt.Errorf("pr.CheckPullFilesProtection(): %v", err)
}
if len(pr.ChangedProtectedFiles) > 0 {
log.Trace("Found %d protected files changed", len(pr.ChangedProtectedFiles))
}

pr.Status = issues_model.PullRequestStatusMergeable
return nil
}
Loading