Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
139 changes: 139 additions & 0 deletions services/pull/conflicts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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.
func checkPullRequestMergeableAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) error {
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()

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.HeadCommitID == "" {
return errors.New("head commit ID is empty for pull request Agit flow")
}

// 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()
// 2.1. 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 err := gitrepo.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, pr.HeadCommitID); err != nil {
return fmt.Errorf("FetchRemoteCommit: %w", err)
}
}
baseCommitID, err := baseGitRepo.GetRefCommitID(git.BranchPrefix + pr.BaseBranch)
if err != nil {
return fmt.Errorf("GetBranchCommitID: can't find commit ID for base: %w", err)
}

// 3. update merge base
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
}

// 4. if base == head, then it's an ancestor
if pr.HeadCommitID == pr.MergeBase {
pr.Status = issues_model.PullRequestStatusAncestor
return nil
}

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

// 6. 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
Loading