Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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 @@ -288,20 +289,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,7 +313,7 @@ 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).
WithEnv(env).
WithDir(repo.Path).
WithDir(repoPath).
WithStdout(stdoutWriter).
WithPipelineFunc(func(ctx context.Context, cancel context.CancelFunc) error {
// Close the writer end of the pipe to begin processing
Expand All @@ -334,9 +333,9 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str
}
return scanner.Err()
}).
Run(repo.Ctx)
Run(ctx)
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
11 changes: 11 additions & 0 deletions modules/git/gitcmd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,17 @@ func IsErrorExitCode(err error, code int) bool {
return false
}

func ExitCode(err error) (int, bool) {
if err == nil {
return 0, true
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), true
}
return 0, false
}

// RunStdString runs the command and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
func (c *Command) RunStdString(ctx context.Context) (stdout, stderr string, runErr RunStdError) {
stdoutBytes, stderrBytes, runErr := c.WithParentCallerInfo().runStdBytes(ctx)
Expand Down
31 changes: 0 additions & 31 deletions modules/git/repo_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,34 +580,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.WithDir(repo.Path).
WithEnv(env).
RunStdBytes(repo.Ctx)
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
}
16 changes: 16 additions & 0 deletions modules/gitrepo/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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 {
return RunCmd(ctx, repo, gitcmd.NewCommand("fetch", "--no-tags").
AddDynamicArguments(repoPath(remoteRepo)).
AddDynamicArguments(commitID))
}
74 changes: 74 additions & 0 deletions modules/gitrepo/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitrepo

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

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

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

// parseMergeTreeOutput parses the output of git merge-tree --write-tree -z --name-only --no-messages
// For a successful merge, the output is a simply one line <OID of toplevel tree>NUL
// Whereas for a conflicted merge, the output is:
// <OID of toplevel tree>NUL
// <Conflicted file name 1>NUL
// <Conflicted file name 2>NUL
// ...
// ref: https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT
func parseMergeTreeOutput(output string) (string, []string, error) {
fields := strings.Split(strings.TrimSuffix(output, "\x00"), "\x00")
if len(fields) == 0 {
return "", nil, errors.New("unexpected empty output")
}
if len(fields) == 1 {
return strings.TrimSpace(fields[0]), nil, nil
}
return strings.TrimSpace(fields[0]), fields[1:], nil
}

// 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) (string, bool, []string, error) {
cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages")
if git.DefaultFeatures().CheckVersionAtLeast("2.40") && mergeBase != "" {
cmd.AddOptionFormat("--merge-base=%s", mergeBase)
}

stdout := &bytes.Buffer{}
gitErr := RunCmd(ctx, repo, cmd.AddDynamicArguments(baseRef, headRef).WithStdout(stdout))
exitCode, ok := gitcmd.ExitCode(gitErr)
if !ok {
return "", false, nil, fmt.Errorf("run merge-tree failed: %w", gitErr)
}

switch exitCode {
case 0, 1:
treeID, conflictedFiles, err := parseMergeTreeOutput(stdout.String())
if err != nil {
return "", false, nil, fmt.Errorf("parse merge-tree output failed: %w", err)
}
// 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
return treeID, exitCode == 1, conflictedFiles, nil
default:
return "", false, nil, fmt.Errorf("run merge-tree exit abnormally: %w", gitErr)
}
}
26 changes: 26 additions & 0 deletions modules/gitrepo/merge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitrepo

import (
"testing"

"github.com/stretchr/testify/assert"
)

func Test_parseMergeTreeOutput(t *testing.T) {
conflictedOutput := "837480c2773160381cbe6bcce90f7732789b5856\x00options/locale/locale_en-US.ini\x00services/webhook/webhook_test.go\x00"
treeID, conflictedFiles, err := parseMergeTreeOutput(conflictedOutput)
assert.NoError(t, err)
assert.Equal(t, "837480c2773160381cbe6bcce90f7732789b5856", treeID)
assert.Len(t, conflictedFiles, 2)
assert.Equal(t, "options/locale/locale_en-US.ini", conflictedFiles[0])
assert.Equal(t, "services/webhook/webhook_test.go", conflictedFiles[1])

nonConflictedOutput := "837480c2773160381cbe6bcce90f7732789b5856\x00"
treeID, conflictedFiles, err = parseMergeTreeOutput(nonConflictedOutput)
assert.NoError(t, err)
assert.Equal(t, "837480c2773160381cbe6bcce90f7732789b5856", treeID)
assert.Empty(t, conflictedFiles)
}
4 changes: 2 additions & 2 deletions routers/private/hook_pre_receive.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,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 @@ -300,7 +300,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
Loading