diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index 688b4e294f5bb..9a41b4284c0c5 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -4,7 +4,6 @@ package git import ( - "os" "path/filepath" "strings" "testing" @@ -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) -} diff --git a/modules/git/diff.go b/modules/git/diff.go index 437b26eb05158..2e2c92ba78d05 100644 --- a/modules/git/diff.go +++ b/modules/git/diff.go @@ -7,6 +7,7 @@ import ( "bufio" "bytes" "context" + "errors" "fmt" "io" "os" @@ -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() { @@ -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 @@ -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 diff --git a/modules/git/git.go b/modules/git/git.go index 6d2c643b33802..9f9e262c7791a 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -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 @@ -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 } diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 5f4487ce7e24b..0ce60a87d9b44 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -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 -} diff --git a/modules/gitrepo/fetch.go b/modules/gitrepo/fetch.go new file mode 100644 index 0000000000000..8b5fa49af7dc0 --- /dev/null +++ b/modules/gitrepo/fetch.go @@ -0,0 +1,17 @@ +// 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 := RunCmdString(ctx, repo, gitcmd.NewCommand("fetch", "--no-tags"). + AddDynamicArguments(repoPath(remoteRepo)). + AddDynamicArguments(commitID)) + return err +} diff --git a/modules/gitrepo/merge.go b/modules/gitrepo/merge.go new file mode 100644 index 0000000000000..7d74de0e0c3bb --- /dev/null +++ b/modules/gitrepo/merge.go @@ -0,0 +1,54 @@ +// 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 := 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 +} + +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 := RunCmd(ctx, repo, cmd.AddDynamicArguments(ours, theirs).WithStdout(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 NUL + // 2. Merge conflict and the output is NULNUL + 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 RunCmd(ctx, repo, gitcmd.NewCommand("diff-tree", "--quiet").AddDynamicArguments(treeHash, mergeBase)) +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 9d69bbcedfaba..547bf1ed32c48 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -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) @@ -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{ diff --git a/services/pull/check.go b/services/pull/check.go index 5b28ec9658d58..4f1abfa451337 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -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 { diff --git a/services/pull/conflicts.go b/services/pull/conflicts.go new file mode 100644 index 0000000000000..f1ddedbd4f26d --- /dev/null +++ b/services/pull/conflicts.go @@ -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 { + 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 +} diff --git a/services/pull/patch.go b/services/pull/patch.go index d82fe3e2251cd..0cdf8453958c2 100644 --- a/services/pull/patch.go +++ b/services/pull/patch.go @@ -1,5 +1,4 @@ -// Copyright 2019 The Gitea Authors. -// All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package pull @@ -15,15 +14,14 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/glob" - "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" ) @@ -67,10 +65,7 @@ var patchErrorSuffices = []string{ ": does not exist in index", } -func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error { - ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("testPullRequestBranchMergeable: %s", pr)) - defer finished() - +func checkPullRequestMergeableAndUpdateStatusTmpRepo(ctx context.Context, pr *issues_model.PullRequest) error { prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) if err != nil { if !git_model.IsErrBranchNotExist(err) { @@ -80,10 +75,6 @@ func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error { } 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) if err != nil { return fmt.Errorf("OpenRepository: %w", err) @@ -115,7 +106,7 @@ func testPullRequestTmpRepoBranchMergeable(ctx context.Context, prCtx *prTmpRepo } // 3. Check for protected files changes - if err = checkPullFilesProtection(ctx, pr, gitRepo); err != nil { + if err = checkPullFilesProtection(ctx, pr, prCtx.tmpBasePath); err != nil { return fmt.Errorf("pr.CheckPullFilesProtection(): %v", err) } @@ -530,11 +521,11 @@ func (err ErrFilePathProtected) Unwrap() error { } // CheckFileProtection check file Protection -func CheckFileProtection(repo *git.Repository, branchName, oldCommitID, newCommitID string, patterns []glob.Glob, limit int, env []string) ([]string, error) { +func CheckFileProtection(ctx context.Context, repoPath, oldCommitID, newCommitID string, patterns []glob.Glob, limit int, env []string) ([]string, error) { if len(patterns) == 0 { return nil, nil } - affectedFiles, err := git.GetAffectedFiles(repo, branchName, oldCommitID, newCommitID, env) + affectedFiles, err := git.GetAffectedFiles(ctx, repoPath, oldCommitID, newCommitID, env) if err != nil { return nil, err } @@ -560,11 +551,11 @@ func CheckFileProtection(repo *git.Repository, branchName, oldCommitID, newCommi } // CheckUnprotectedFiles check if the commit only touches unprotected files -func CheckUnprotectedFiles(repo *git.Repository, branchName, oldCommitID, newCommitID string, patterns []glob.Glob, env []string) (bool, error) { +func CheckUnprotectedFiles(ctx context.Context, repo *repo_model.Repository, oldCommitID, newCommitID string, patterns []glob.Glob, env []string) (bool, error) { if len(patterns) == 0 { return false, nil } - affectedFiles, err := git.GetAffectedFiles(repo, branchName, oldCommitID, newCommitID, env) + affectedFiles, err := git.GetAffectedFiles(ctx, repo.RepoPath(), oldCommitID, newCommitID, env) if err != nil { return false, err } @@ -585,7 +576,8 @@ func CheckUnprotectedFiles(repo *git.Repository, branchName, oldCommitID, newCom } // checkPullFilesProtection check if pr changed protected files and save results -func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) error { +// repoPath might be a temporary path so that we need to pass it in +func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, repoPath string) error { if pr.Status == issues_model.PullRequestStatusEmpty { pr.ChangedProtectedFiles = nil return nil @@ -601,7 +593,7 @@ func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, return nil } - pr.ChangedProtectedFiles, err = CheckFileProtection(gitRepo, pr.HeadBranch, pr.MergeBase, "tracking", pb.GetProtectedFilePatterns(), 10, os.Environ()) + pr.ChangedProtectedFiles, err = CheckFileProtection(ctx, repoPath, pr.MergeBase, pr.HeadCommitID, pb.GetProtectedFilePatterns(), 10, os.Environ()) if err != nil && !IsErrFilePathProtected(err) { return err } diff --git a/services/pull/pull.go b/services/pull/pull.go index 619347055b7d3..34022b24fea1f 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -30,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -86,16 +87,12 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { } } - prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) - if err != nil { - if !git_model.IsErrBranchNotExist(err) { - log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err) - } - return err - } - defer cancel() + ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("testPullRequestBranchMergeable: %s", pr)) + defer finished() - if err := testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr); err != nil { + // the pull request haven't been created + err := checkPullRequestMergeableAndUpdateStatus(ctx, pr) + if err != nil { return err } @@ -286,8 +283,11 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer oldBranch := pr.BaseBranch pr.BaseBranch = targetBranch + ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("testPullRequestBranchMergeable: %s", pr)) + defer finished() + // Refresh patch - if err := testPullRequestBranchMergeable(pr); err != nil { + if err := checkPullRequestMergeableAndUpdateStatus(ctx, pr); err != nil { return err } diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index 197a20bc8ce6a..5a0e0108e270d 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -229,3 +229,18 @@ func doGitPull(dstPath string, args ...string) func(*testing.T) { assert.NoError(t, err) } } + +func doGitCommit(dstPath, commitMessage string) func(*testing.T) { + return func(t *testing.T) { + signature := git.Signature{ + Email: "test@test.test", + Name: "test", + When: time.Now(), + } + assert.NoError(t, git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + Committer: &signature, + Author: &signature, + Message: commitMessage, + })) + } +} diff --git a/tests/integration/pull_comment_test.go b/tests/integration/pull_comment_test.go index abab65247ba74..5323b1cb72b2d 100644 --- a/tests/integration/pull_comment_test.go +++ b/tests/integration/pull_comment_test.go @@ -13,6 +13,8 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/test" issues_service "code.gitea.io/gitea/services/issue" "github.com/stretchr/testify/assert" @@ -25,7 +27,7 @@ func testWaitForPullRequestStatus(t *testing.T, prIssue *issues_model.Issue, exp retIssue = unittest.AssertExistsAndLoadBean(t, &prIssueCond) require.NoError(t, retIssue.LoadPullRequest(t.Context())) return retIssue.PullRequest.Status == expectedStatus - }, 5*time.Second, 20*time.Millisecond) + }, 10*time.Second, 20*time.Millisecond) return retIssue } @@ -96,14 +98,40 @@ func testPullCommentRetarget(t *testing.T, u *url.URL, session *TestSession) { testWaitForPullRequestStatus(t, &issues_model.Issue{Title: testPRTitle}, issues_model.PullRequestStatusMergeable) } -func TestPullComment(t *testing.T) { +func TestPullComment_MergeTree(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") testCreateBranch(t, session, "user2", "repo1", "branch/master", "test-branch/rebase", http.StatusSeeOther) testCreateBranch(t, session, "user2", "repo1", "branch/master", "test-branch/retarget", http.StatusSeeOther) testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - t.Run("RebaseComment", func(t *testing.T) { testPullCommentRebase(t, u, session) }) - t.Run("RetargetComment", func(t *testing.T) { testPullCommentRetarget(t, u, session) }) + t.Run("RebaseComment_MergeTree", func(t *testing.T) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullCommentRebase(t, u, session) + }) + + t.Run("RetargetComment_MergeTree", func(t *testing.T) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullCommentRetarget(t, u, session) + }) + }) +} + +func TestPullComment_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testCreateBranch(t, session, "user2", "repo1", "branch/master", "test-branch/rebase", http.StatusSeeOther) + testCreateBranch(t, session, "user2", "repo1", "branch/master", "test-branch/retarget", http.StatusSeeOther) + testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") + + t.Run("RebaseComment_TmpRepo", func(t *testing.T) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullCommentRebase(t, u, session) + }) + + t.Run("RetargetComment_TmpRepo", func(t *testing.T) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullCommentRetarget(t, u, session) + }) }) } diff --git a/tests/integration/pull_commit_test.go b/tests/integration/pull_commit_test.go index 9f3b1a9ef53d0..4fa8e4d9d24e0 100644 --- a/tests/integration/pull_commit_test.go +++ b/tests/integration/pull_commit_test.go @@ -4,9 +4,18 @@ package integration import ( + "fmt" "net/http" + "net/url" + "path" "testing" + auth_model "code.gitea.io/gitea/models/auth" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/commitstatus" + api "code.gitea.io/gitea/modules/structs" pull_service "code.gitea.io/gitea/services/pull" "code.gitea.io/gitea/tests" @@ -39,3 +48,98 @@ func TestListPullCommits(t *testing.T) { assert.Contains(t, resp.Body.String(), `# repo1`) }) } + +func TestPullCreate_CommitStatus(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") + + url := path.Join("user1", "repo1", "compare", "master...status1") + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "title": "pull request from status1", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user1/repo1/pulls") + resp := session.MakeRequest(t, req, http.StatusOK) + NewHTMLParser(t, resp.Body) + + // Request repository commits page + req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") + resp = session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + commitID := path.Base(commitURL) + + statusList := []commitstatus.CommitStatusState{ + commitstatus.CommitStatusPending, + commitstatus.CommitStatusError, + commitstatus.CommitStatusFailure, + commitstatus.CommitStatusSuccess, + commitstatus.CommitStatusWarning, + } + + statesIcons := map[commitstatus.CommitStatusState]string{ + commitstatus.CommitStatusPending: "octicon-dot-fill", + commitstatus.CommitStatusSuccess: "octicon-check", + commitstatus.CommitStatusError: "gitea-exclamation", + commitstatus.CommitStatusFailure: "octicon-x", + commitstatus.CommitStatusWarning: "gitea-exclamation", + } + + testCtx := NewAPITestContext(t, "user1", "repo1", auth_model.AccessTokenScopeWriteRepository) + + // Update commit status, and check if icon is updated as well + for _, status := range statusList { + // Call API to add status for commit + t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{ + State: status, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + })) + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + + commitURL, exists = doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + assert.Equal(t, commitID, path.Base(commitURL)) + + cls, ok := doc.doc.Find("#commits-table tbody tr td.message .commit-status").Last().Attr("class") + assert.True(t, ok) + assert.Contains(t, cls, statesIcons[status]) + } + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"}) + css := unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusSummary{RepoID: repo1.ID, SHA: commitID}) + assert.Equal(t, commitstatus.CommitStatusSuccess, css.State) + }) +} + +func doAPICreateCommitStatus(ctx APITestContext, commitID string, data api.CreateStatusOption) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON( + t, + http.MethodPost, + fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, commitID), + data, + ).AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusCreated) + } +} diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 7670aebab5349..caadbe91b2c2e 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -36,9 +36,7 @@ import ( "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/automergequeue" pull_service "code.gitea.io/gitea/services/pull" - repo_service "code.gitea.io/gitea/services/repository" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" - files_service "code.gitea.io/gitea/services/repository/files" "github.com/stretchr/testify/assert" ) @@ -461,86 +459,6 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) { }) } -func TestConflictChecking(t *testing.T) { - onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - - // Create new clean repo to test conflict checking. - baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ - Name: "conflict-checking", - Description: "Tempo repo", - AutoInit: true, - Readme: "Default", - DefaultBranch: "main", - }) - assert.NoError(t, err) - assert.NotEmpty(t, baseRepo) - - // create a commit on new branch. - _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "create", - TreePath: "important_file", - ContentReader: strings.NewReader("Just a non-important file"), - }, - }, - Message: "Add a important file", - OldBranch: "main", - NewBranch: "important-secrets", - }) - assert.NoError(t, err) - - // create a commit on main branch. - _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "create", - TreePath: "important_file", - ContentReader: strings.NewReader("Not the same content :P"), - }, - }, - Message: "Add a important file", - OldBranch: "main", - NewBranch: "main", - }) - assert.NoError(t, err) - - // create Pull to merge the important-secrets branch into main branch. - pullIssue := &issues_model.Issue{ - RepoID: baseRepo.ID, - Title: "PR with conflict!", - PosterID: user.ID, - Poster: user, - IsPull: true, - } - - pullRequest := &issues_model.PullRequest{ - HeadRepoID: baseRepo.ID, - BaseRepoID: baseRepo.ID, - HeadBranch: "important-secrets", - BaseBranch: "main", - HeadRepo: baseRepo, - BaseRepo: baseRepo, - Type: issues_model.PullRequestGitea, - } - prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} - err = pull_service.NewPullRequest(t.Context(), prOpts) - assert.NoError(t, err) - - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) - assert.NoError(t, issue.LoadPullRequest(t.Context())) - conflictingPR := issue.PullRequest - - // Ensure conflictedFiles is populated. - assert.Len(t, conflictingPR.ConflictedFiles, 1) - // Check if status is correct. - assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status) - // Ensure that mergeable returns false - assert.False(t, conflictingPR.Mergeable(t.Context())) - }) -} - func TestPullRetargetChildOnBranchDelete(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go index 49326a594aee6..c5f5d29fa9cb6 100644 --- a/tests/integration/pull_status_test.go +++ b/tests/integration/pull_status_test.go @@ -7,119 +7,27 @@ import ( "fmt" "net/http" "net/url" + "os" "path" + "path/filepath" "strings" "testing" - auth_model "code.gitea.io/gitea/models/auth" - git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/models/issues" + issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/commitstatus" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/setting" - api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" - "code.gitea.io/gitea/services/pull" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" "github.com/stretchr/testify/assert" ) -func TestPullCreate_CommitStatus(t *testing.T) { - onGiteaRun(t, func(t *testing.T, u *url.URL) { - session := loginUser(t, "user1") - testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") - - url := path.Join("user1", "repo1", "compare", "master...status1") - req := NewRequestWithValues(t, "POST", url, - map[string]string{ - "_csrf": GetUserCSRFToken(t, session), - "title": "pull request from status1", - }, - ) - session.MakeRequest(t, req, http.StatusOK) - - req = NewRequest(t, "GET", "/user1/repo1/pulls") - resp := session.MakeRequest(t, req, http.StatusOK) - NewHTMLParser(t, resp.Body) - - // Request repository commits page - req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") - resp = session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body) - - // Get first commit URL - commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") - assert.True(t, exists) - assert.NotEmpty(t, commitURL) - - commitID := path.Base(commitURL) - - statusList := []commitstatus.CommitStatusState{ - commitstatus.CommitStatusPending, - commitstatus.CommitStatusError, - commitstatus.CommitStatusFailure, - commitstatus.CommitStatusSuccess, - commitstatus.CommitStatusWarning, - } - - statesIcons := map[commitstatus.CommitStatusState]string{ - commitstatus.CommitStatusPending: "octicon-dot-fill", - commitstatus.CommitStatusSuccess: "octicon-check", - commitstatus.CommitStatusError: "gitea-exclamation", - commitstatus.CommitStatusFailure: "octicon-x", - commitstatus.CommitStatusWarning: "gitea-exclamation", - } - - testCtx := NewAPITestContext(t, "user1", "repo1", auth_model.AccessTokenScopeWriteRepository) - - // Update commit status, and check if icon is updated as well - for _, status := range statusList { - // Call API to add status for commit - t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{ - State: status, - TargetURL: "http://test.ci/", - Description: "", - Context: "testci", - })) - - req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") - resp = session.MakeRequest(t, req, http.StatusOK) - doc = NewHTMLParser(t, resp.Body) - - commitURL, exists = doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") - assert.True(t, exists) - assert.NotEmpty(t, commitURL) - assert.Equal(t, commitID, path.Base(commitURL)) - - cls, ok := doc.doc.Find("#commits-table tbody tr td.message .commit-status").Last().Attr("class") - assert.True(t, ok) - assert.Contains(t, cls, statesIcons[status]) - } - - repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"}) - css := unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusSummary{RepoID: repo1.ID, SHA: commitID}) - assert.Equal(t, commitstatus.CommitStatusSuccess, css.State) - }) -} - -func doAPICreateCommitStatus(ctx APITestContext, commitID string, data api.CreateStatusOption) func(*testing.T) { - return func(t *testing.T) { - req := NewRequestWithJSON( - t, - http.MethodPost, - fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, commitID), - data, - ).AddTokenAuth(ctx.Token) - if ctx.ExpectedCode != 0 { - ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) - return - } - ctx.Session.MakeRequest(t, req, http.StatusCreated) - } -} - func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { // Merge must continue if commits SHA are different, even if content is same // Reason: gitflow and merging master back into develop, where is high possibility, there are no changes @@ -174,16 +82,16 @@ func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) { func TestPullStatusDelayCheck(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { defer test.MockVariableValue(&setting.Repository.PullRequest.DelayCheckForInactiveDays, 1)() - defer test.MockVariableValue(&pull.AddPullRequestToCheckQueue)() + defer test.MockVariableValue(&pull_service.AddPullRequestToCheckQueue)() session := loginUser(t, "user2") - run := func(t *testing.T, fn func(*testing.T)) (issue3 *issues.Issue, checkedPrID int64) { - pull.AddPullRequestToCheckQueue = func(prID int64) { + run := func(t *testing.T, fn func(*testing.T)) (issue3 *issues_model.Issue, checkedPrID int64) { + pull_service.AddPullRequestToCheckQueue = func(prID int64) { checkedPrID = prID } fn(t) - issue3 = unittest.AssertExistsAndLoadBean(t, &issues.Issue{RepoID: 1, Index: 3}) + issue3 = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 1, Index: 3}) _ = issue3.LoadPullRequest(t.Context()) return issue3, checkedPrID } @@ -201,7 +109,7 @@ func TestPullStatusDelayCheck(t *testing.T) { // PR issue3 is merageable at the beginning issue3, checkedPrID := run(t, func(t *testing.T) {}) - assert.Equal(t, issues.PullRequestStatusMergeable, issue3.PullRequest.Status) + assert.Equal(t, issues_model.PullRequestStatusMergeable, issue3.PullRequest.Status) assert.Zero(t, checkedPrID) assertReloadingInterval(t, "") // the PR is mergeable, so no need to reload the merge box @@ -213,7 +121,7 @@ func TestPullStatusDelayCheck(t *testing.T) { issue3, checkedPrID = run(t, func(t *testing.T) { testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 1") }) - assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status) + assert.Equal(t, issues_model.PullRequestStatusChecking, issue3.PullRequest.Status) assert.Zero(t, checkedPrID) assertReloadingInterval(t, "2000") // the PR status is "checking", so try to reload the merge box @@ -222,14 +130,14 @@ func TestPullStatusDelayCheck(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/pulls/3") session.MakeRequest(t, req, http.StatusOK) }) - assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status) + assert.Equal(t, issues_model.PullRequestStatusChecking, issue3.PullRequest.Status) assert.Equal(t, issue3.PullRequest.ID, checkedPrID) // when base branch changes, still so no real check issue3, checkedPrID = run(t, func(t *testing.T) { testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 2") }) - assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status) + assert.Equal(t, issues_model.PullRequestStatusChecking, issue3.PullRequest.Status) assert.Zero(t, checkedPrID) // then allow to check PRs without delay, when base branch changes, the PRs will be checked @@ -237,7 +145,517 @@ func TestPullStatusDelayCheck(t *testing.T) { issue3, checkedPrID = run(t, func(t *testing.T) { testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 3") }) - assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status) + assert.Equal(t, issues_model.PullRequestStatusChecking, issue3.PullRequest.Status) assert.Equal(t, issue3.PullRequest.ID, checkedPrID) }) } + +func Test_PullRequestStatusChecking_Mergeable_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullRequestStatusCheckingMergeable(t) + }) +} + +func Test_PullRequestStatusChecking_Mergeable_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullRequestStatusCheckingMergeable(t) + }) +} + +func testPullRequestStatusCheckingMergeable(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + // create a commit on new branch. + _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Just a non-important file"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "important-secrets", + }) + assert.NoError(t, err) + + // create Pull to merge the important-secrets branch into main branch. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "PR with no conflict", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: baseRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "important-secrets", + BaseBranch: "main", + HeadRepo: baseRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(t.Context(), prOpts) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with no conflict"}) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Empty(t, conflictingPR.ConflictedFiles) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusMergeable, conflictingPR.Status) + // Ensure that mergeable returns true + assert.True(t, conflictingPR.Mergeable(t.Context())) +} + +func Test_PullRequestStatusChecking_Conflicted_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullRequestStatusCheckingConflicted(t) + }) +} + +func Test_PullRequestStatusChecking_Conflicted_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullRequestStatusCheckingConflicted(t) + }) +} + +func testPullRequestStatusCheckingConflicted(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + // create a commit on new branch. + _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Just a non-important file"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "important-secrets", + }) + assert.NoError(t, err) + + // create a commit on main branch. + _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Not the same content :P"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "main", + }) + assert.NoError(t, err) + + // create Pull to merge the important-secrets branch into main branch. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "PR with conflict!", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: baseRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "important-secrets", + BaseBranch: "main", + HeadRepo: baseRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(t.Context(), prOpts) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Len(t, conflictingPR.ConflictedFiles, 1) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status) + // Ensure that mergeable returns false + assert.False(t, conflictingPR.Mergeable(t.Context())) +} + +func Test_PullRequestStatusCheckingCrossRepo_Mergeable_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullRequestStatusCheckingCrossRepoMergeable(t, giteaURL) + }) +} + +func Test_PullRequestStatusCheckingCrossRepo_Mergeable_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullRequestStatusCheckingCrossRepoMergeable(t, giteaURL) + }) +} + +func testPullRequestStatusCheckingCrossRepoMergeable(t *testing.T, giteaURL *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + testRepoFork(t, session, baseRepo.OwnerName, baseRepo.Name, "org3", "conflict-checking", "main") + + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "org3", Name: "conflict-checking"}) + + // create a commit on new branch of forked repository + _, err = files_service.ChangeRepoFiles(t.Context(), forkRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Just a non-important file"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "important-secrets", + }) + assert.NoError(t, err) + + // create Pull to merge the important-secrets branch into main branch. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "PR with no conflict", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: forkRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "important-secrets", + BaseBranch: "main", + HeadRepo: forkRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + prOpts := &pull_service.NewPullRequestOptions{ + Repo: baseRepo, + Issue: pullIssue, + PullRequest: pullRequest, + } + err = pull_service.NewPullRequest(t.Context(), prOpts) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with no conflict"}) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Empty(t, conflictingPR.ConflictedFiles) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusMergeable, conflictingPR.Status) + // Ensure that mergeable returns true + assert.True(t, conflictingPR.Mergeable(t.Context())) +} + +func Test_PullRequestStatusCheckingCrossRepo_Conflicted_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + testPullRequestStatusCheckingCrossRepoConflicted(t, giteaURL) + }) +} + +func Test_PullRequestStatusCheckingCrossRepo_Conflicted_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + testPullRequestStatusCheckingCrossRepoConflicted(t, giteaURL) + }) +} + +func testPullRequestStatusCheckingCrossRepoConflicted(t *testing.T, giteaURL *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + testRepoFork(t, session, baseRepo.OwnerName, baseRepo.Name, "org3", "conflict-checking", "main") + + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "org3", Name: "conflict-checking"}) + + // create a commit on new branch of forked repository + _, err = files_service.ChangeRepoFiles(t.Context(), forkRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Just a non-important file"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "important-secrets", + }) + assert.NoError(t, err) + + // create a commit on main branch of base repository. + _, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Not the same content :P"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "main", + }) + assert.NoError(t, err) + + // create Pull to merge the important-secrets branch into main branch. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "PR with conflict!", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: forkRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "important-secrets", + BaseBranch: "main", + HeadRepo: forkRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(t.Context(), prOpts) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Len(t, conflictingPR.ConflictedFiles, 1) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status) + // Ensure that mergeable returns false + assert.False(t, conflictingPR.Mergeable(t.Context())) +} + +func Test_PullRequest_AGit_StatusChecking_Mergeable_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // skip this test if git version is low + if !git.DefaultFeatures().SupportProcReceive { + return + } + + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + + testPullRequestAGitStatusCheckingMergeable(t, giteaURL) + }) +} + +func Test_PullRequest_AGit_StatusChecking_Mergeable_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + + testPullRequestAGitStatusCheckingMergeable(t, giteaURL) + }) +} + +func testPullRequestAGitStatusCheckingMergeable(t *testing.T, giteaURL *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + // add something in local repository and push it to remote + dstPath := t.TempDir() + repoURL := *giteaURL + repoURL.Path = baseRepo.FullName() + ".git" + repoURL.User = url.UserPassword("user2", userPassword) + doGitClone(dstPath, &repoURL)(t) + + gitRepo, err := git.OpenRepository(t.Context(), dstPath) + assert.NoError(t, err) + defer gitRepo.Close() + + doGitCreateBranch(dstPath, "test-agit-push")(t) + + _, err = generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-") + assert.NoError(t, err) + + // push to create an agit pull request + err = gitcmd.NewCommand("push", "origin", + "-o", "title=agit-test-title", "-o", "description=agit-test-description", + "-o", "topic=head-branch-name", + "HEAD:refs/for/main", + ).WithDir(dstPath).Run(t.Context()) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "agit-test-title", + }) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Empty(t, conflictingPR.ConflictedFiles) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusMergeable, conflictingPR.Status) + // Ensure that mergeable returns true + assert.True(t, conflictingPR.Mergeable(t.Context())) +} + +func Test_PullRequest_AGit_StatusChecking_Conflicted_MergeTree(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, true)() + + testPullRequestAGitStatusCheckingConflicted(t, giteaURL) + }) +} + +func Test_PullRequest_AGit_StatusChecking_Conflicted_TmpRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + defer test.MockVariableValue(&git.DefaultFeatures().SupportGitMergeTree, false)() + + testPullRequestAGitStatusCheckingConflicted(t, giteaURL) + }) +} + +func testPullRequestAGitStatusCheckingConflicted(t *testing.T, giteaURL *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create new clean repo to test conflict checking. + baseRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{ + Name: "conflict-checking", + Description: "Tempo repo", + AutoInit: true, + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, baseRepo) + + // add something in local repository and push it to remote + dstPath := t.TempDir() + repoURL := *giteaURL + repoURL.Path = baseRepo.FullName() + ".git" + repoURL.User = url.UserPassword("user2", userPassword) + doGitClone(dstPath, &repoURL)(t) + + gitRepo, err := git.OpenRepository(t.Context(), dstPath) + assert.NoError(t, err) + defer gitRepo.Close() + + // create agit branch from current commit + doGitCreateBranch(dstPath, "test-agit-push")(t) + + // add something on the same file of main branch so that it causes conflict + doGitCheckoutBranch(dstPath, "main")(t) + + assert.NoError(t, os.WriteFile(filepath.Join(dstPath, "README.md"), []byte("Some changes to README file to main cause conflict"), 0o644)) + assert.NoError(t, git.AddChanges(t.Context(), dstPath, true)) + doGitCommit(dstPath, "add something to main branch")(t) + + err = gitcmd.NewCommand("push", "origin", "main").WithDir(dstPath).Run(t.Context()) + assert.NoError(t, err) + + // check out back to agit branch and change the same file + doGitCheckoutBranch(dstPath, "test-agit-push")(t) + + assert.NoError(t, os.WriteFile(filepath.Join(dstPath, "README.md"), []byte("Some changes to README file for agit branch"), 0o644)) + assert.NoError(t, git.AddChanges(t.Context(), dstPath, true)) + doGitCommit(dstPath, "add something to agit branch")(t) + + // push to create an agit pull request + err = gitcmd.NewCommand("push", "origin", + "-o", "title=agit-test-title", "-o", "description=agit-test-description", + "-o", "topic=head-branch-name", + "HEAD:refs/for/main", + ).WithDir(dstPath).Run(t.Context()) + assert.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "agit-test-title", + }) + assert.NoError(t, issue.LoadPullRequest(t.Context())) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Equal(t, []string{"README.md"}, conflictingPR.ConflictedFiles) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status) + // Ensure that mergeable returns false + assert.False(t, conflictingPR.Mergeable(t.Context())) +}