Support "merge upstream branch" (Sync fork) (#32741)

Add basic "sync fork" support (GitHub-like)

<details>

![image](https://github.com/user-attachments/assets/e71473f4-4518-48c7-b9e2-fedfcd564fc3)

</details>
This commit is contained in:
wxiaoguang 2024-12-07 05:10:35 +08:00 committed by GitHub
parent 5a75160c92
commit 513da407f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 328 additions and 141 deletions

View File

@ -223,7 +223,7 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
if err != nil {
if strings.Contains(stderr, "non-fast-forward") {
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
} else if strings.Contains(stderr, "! [remote rejected]") {
} else if strings.Contains(stderr, "! [remote rejected]") || strings.Contains(stderr, "! [rejected]") {
err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err}
err.GenerateMessage()
return err

View File

@ -1946,6 +1946,10 @@ pulls.delete.title = Delete this pull request?
pulls.delete.text = Do you really want to delete this pull request? (This will permanently remove all content. Consider closing it instead, if you intend to keep it archived)
pulls.recently_pushed_new_branches = You pushed on branch <strong>%[1]s</strong> %[2]s
pulls.upstream_diverging_prompt_behind_1 = This branch is %d commit behind %s
pulls.upstream_diverging_prompt_behind_n = This branch is %d commits behind %s
pulls.upstream_diverging_prompt_base_newer = The base branch %s has new changes
pulls.upstream_diverging_merge = Sync fork
pull.deleted_branch = (deleted):%s
pull.agit_documentation = Review documentation about AGit

View File

@ -259,3 +259,20 @@ func CreateBranch(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(form.NewBranchName) + "/" + util.PathEscapeSegments(form.CurrentPath))
}
func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONError(ctx.Tr("error.not_found"))
return
} else if models.IsErrMergeConflicts(err) {
ctx.JSONError(ctx.Tr("repo.pulls.merge_conflict"))
return
}
ctx.ServerError("MergeUpstream", err)
return
}
ctx.JSONRedirect("")
}

View File

@ -31,7 +31,7 @@ import (
"github.com/nektos/act/pkg/model"
)
func renderFile(ctx *context.Context, entry *git.TreeEntry) {
func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsViewFile"] = true
ctx.Data["HideRepoInfo"] = true
blob := entry.Blob()

View File

@ -4,6 +4,7 @@
package repo
import (
"errors"
"fmt"
"html/template"
"net/http"
@ -86,29 +87,31 @@ func prepareOpenWithEditorApps(ctx *context.Context) {
ctx.Data["OpenWithEditorApps"] = tmplApps
}
func prepareHomeSidebarCitationFile(ctx *context.Context, entry *git.TreeEntry) {
if entry.Name() != "" {
return
}
tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.SubTree", err)
return
}
allEntries, err := tree.ListEntries()
if err != nil {
ctx.ServerError("ListEntries", err)
return
}
for _, entry := range allEntries {
if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
// Read Citation file contents
if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("checkCitationFile: GetBlobContent: %v", err)
} else {
ctx.Data["CitiationExist"] = true
ctx.PageData["citationFileContent"] = content
break
func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) {
return func(ctx *context.Context) {
if entry.Name() != "" {
return
}
tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.SubTree", err)
return
}
allEntries, err := tree.ListEntries()
if err != nil {
ctx.ServerError("ListEntries", err)
return
}
for _, entry := range allEntries {
if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
// Read Citation file contents
if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("checkCitationFile: GetBlobContent: %v", err)
} else {
ctx.Data["CitiationExist"] = true
ctx.PageData["citationFileContent"] = content
break
}
}
}
}
@ -174,83 +177,21 @@ func prepareHomeSidebarLatestRelease(ctx *context.Context) {
}
}
func renderHomeCode(ctx *context.Context) {
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled
prepareOpenWithEditorApps(ctx)
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
showEmpty := true
var err error
if ctx.Repo.GitRepo != nil {
showEmpty, err = ctx.Repo.GitRepo.IsEmpty()
if err != nil {
log.Error("GitRepo.IsEmpty: %v", err)
ctx.Repo.Repository.Status = repo_model.RepositoryBroken
showEmpty = true
ctx.Flash.Error(ctx.Tr("error.occurred"), true)
}
}
if showEmpty {
ctx.HTML(http.StatusOK, tplRepoEMPTY)
return
}
// the repo is not really empty, so we should update the modal in database
// such problem may be caused by:
// 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually
// and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos.
// it's possible for a repository to be non-empty by that flag but still 500
// because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed.
ctx.Repo.Repository.IsEmpty = false
if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil {
ctx.ServerError("UpdateRepositoryCols", err)
return
}
if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("UpdateRepoSize", err)
return
}
// the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values
link := ctx.Link
if ctx.Req.URL.RawQuery != "" {
link += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(link)
func prepareUpstreamDivergingInfo(ctx *context.Context) {
if !ctx.Repo.Repository.IsFork || !ctx.Repo.IsViewBranch || ctx.Repo.TreePath != "" {
return
}
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
if len(ctx.Repo.Repository.Description) > 0 {
title += ": " + ctx.Repo.Repository.Description
}
ctx.Data["Title"] = title
// Get Topics of this repo
prepareHomeSidebarRepoTopics(ctx)
if ctx.Written() {
return
}
// Get current entry user currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
upstreamDivergingInfo, err := repo_service.GetUpstreamDivergingInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName)
if err != nil {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return
}
checkOutdatedBranch(ctx)
if entry.IsDir() {
prepareToRenderDirectory(ctx)
} else {
renderFile(ctx, entry)
}
if ctx.Written() {
if !errors.Is(err, util.ErrNotExist) && !errors.Is(err, util.ErrInvalidArgument) {
log.Error("GetUpstreamDivergingInfo: %v", err)
}
return
}
ctx.Data["UpstreamDivergingInfo"] = upstreamDivergingInfo
}
func prepareRecentlyPushedNewBranches(ctx *context.Context) {
if ctx.Doer != nil {
if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil {
ctx.ServerError("GetBaseRepo", err)
@ -280,7 +221,112 @@ func renderHomeCode(ctx *context.Context) {
}
}
}
}
func handleRepoEmptyOrBroken(ctx *context.Context) {
showEmpty := true
var err error
if ctx.Repo.GitRepo != nil {
showEmpty, err = ctx.Repo.GitRepo.IsEmpty()
if err != nil {
log.Error("GitRepo.IsEmpty: %v", err)
ctx.Repo.Repository.Status = repo_model.RepositoryBroken
showEmpty = true
ctx.Flash.Error(ctx.Tr("error.occurred"), true)
}
}
if showEmpty {
ctx.HTML(http.StatusOK, tplRepoEMPTY)
return
}
// the repo is not really empty, so we should update the modal in database
// such problem may be caused by:
// 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually
// and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos.
// it's possible for a repository to be non-empty by that flag but still 500
// because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed.
ctx.Repo.Repository.IsEmpty = false
if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil {
ctx.ServerError("UpdateRepositoryCols", err)
return
}
if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("UpdateRepoSize", err)
return
}
// the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values
link := ctx.Link
if ctx.Req.URL.RawQuery != "" {
link += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(link)
}
func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) {
return func(ctx *context.Context) {
if entry.IsDir() {
prepareToRenderDirectory(ctx)
} else {
prepareToRenderFile(ctx, entry)
}
}
}
func handleRepoHomeFeed(ctx *context.Context) bool {
if setting.Other.EnableFeed {
isFeed, _, showFeedType := feed.GetFeedType(ctx.PathParam(":reponame"), ctx.Req)
if isFeed {
switch {
case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType):
feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType)
case ctx.Repo.TreePath == "":
feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
case ctx.Repo.TreePath != "":
feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
}
return true
}
}
return false
}
// Home render repository home page
func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) {
return
}
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
if ctx.Written() {
return
}
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
if len(ctx.Repo.Repository.Description) > 0 {
title += ": " + ctx.Repo.Repository.Description
}
ctx.Data["Title"] = title
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
// empty or broken repositories need to be handled differently
handleRepoEmptyOrBroken(ctx)
return
}
// get the current git entry which doer user is currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return
}
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
treeLink := branchLink
@ -295,57 +341,38 @@ func renderHomeCode(ctx *context.Context) {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
isTreePathRoot := ctx.Repo.TreePath == ""
if isTreePathRoot {
prepareHomeSidebarLicenses(ctx)
if ctx.Written() {
return
}
prepareHomeSidebarCitationFile(ctx, entry)
if ctx.Written() {
return
}
prepareHomeSidebarLanguageStats(ctx)
if ctx.Written() {
return
}
prepareHomeSidebarLatestRelease(ctx)
if ctx.Written() {
return
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
ctx.HTML(http.StatusOK, tplRepoHome)
}
// Home render repository home page
func Home(ctx *context.Context) {
if setting.Other.EnableFeed {
isFeed, _, showFeedType := feed.GetFeedType(ctx.PathParam(":reponame"), ctx.Req)
if isFeed {
switch {
case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType):
feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType)
case ctx.Repo.TreePath == "":
feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
case ctx.Repo.TreePath != "":
feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
}
// some UI components are only shown when the tree path is root
isTreePathRoot := ctx.Repo.TreePath == ""
prepareFuncs := []func(*context.Context){
prepareOpenWithEditorApps,
prepareHomeSidebarRepoTopics,
checkOutdatedBranch,
prepareToRenderDirOrFile(entry),
prepareRecentlyPushedNewBranches,
}
if isTreePathRoot {
prepareFuncs = append(prepareFuncs,
prepareUpstreamDivergingInfo,
prepareHomeSidebarLicenses,
prepareHomeSidebarCitationFile(entry),
prepareHomeSidebarLanguageStats,
prepareHomeSidebarLatestRelease,
)
}
for _, prepare := range prepareFuncs {
prepare(ctx)
if ctx.Written() {
return
}
}
checkHomeCodeViewable(ctx)
if ctx.Written() {
return
}
renderHomeCode(ctx)
ctx.HTML(http.StatusOK, tplRepoHome)
}

View File

@ -1320,6 +1320,7 @@ func registerRoutes(m *web.Router) {
m.Post("/delete", repo.DeleteBranchPost)
m.Post("/restore", repo.RestoreBranchPost)
m.Post("/rename", web.Bind(forms.RenameBranchForm{}), repo_setting.RenameBranchPost)
m.Post("/merge-upstream", repo.MergeUpstream)
}, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty)
m.Combo("/fork").Get(repo.Fork).Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost)

View File

@ -65,7 +65,9 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
return fmt.Errorf("unable to load HeadRepo for PR[%d] during update-by-merge: %w", pr.ID, err)
}
// use merge functions but switch repos and branches
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
// now use a fake reverse PR to switch head&base repos/branches
reversePR := &issues_model.PullRequest{
ID: pr.ID,

View File

@ -0,0 +1,115 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"context"
"fmt"
git_model "code.gitea.io/gitea/models/git"
issue_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/pull"
)
type UpstreamDivergingInfo struct {
BaseIsNewer bool
CommitsBehind int
CommitsAhead int
}
func MergeUpstream(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) (mergeStyle string, err error) {
if err = repo.MustNotBeArchived(); err != nil {
return "", err
}
if err = repo.GetBaseRepo(ctx); err != nil {
return "", err
}
err = git.Push(ctx, repo.BaseRepo.RepoPath(), git.PushOptions{
Remote: repo.RepoPath(),
Branch: fmt.Sprintf("%s:%s", branch, branch),
Env: repo_module.PushingEnvironment(doer, repo),
})
if err == nil {
return "fast-forward", nil
}
if !git.IsErrPushOutOfDate(err) && !git.IsErrPushRejected(err) {
return "", err
}
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
fakeIssue := &issue_model.Issue{
ID: -1,
RepoID: repo.ID,
Repo: repo,
Index: -1,
PosterID: doer.ID,
Poster: doer,
IsPull: true,
}
fakePR := &issue_model.PullRequest{
ID: -1,
Status: issue_model.PullRequestStatusMergeable,
IssueID: -1,
Issue: fakeIssue,
Index: -1,
HeadRepoID: repo.ID,
HeadRepo: repo,
BaseRepoID: repo.BaseRepo.ID,
BaseRepo: repo.BaseRepo,
HeadBranch: branch, // maybe HeadCommitID is not needed
BaseBranch: branch,
}
fakeIssue.PullRequest = fakePR
err = pull.Update(ctx, fakePR, doer, "merge upstream", false)
if err != nil {
return "", err
}
return "merge", nil
}
func GetUpstreamDivergingInfo(ctx context.Context, repo *repo_model.Repository, branch string) (*UpstreamDivergingInfo, error) {
if !repo.IsFork {
return nil, util.NewInvalidArgumentErrorf("repo is not a fork")
}
if repo.IsArchived {
return nil, util.NewInvalidArgumentErrorf("repo is archived")
}
if err := repo.GetBaseRepo(ctx); err != nil {
return nil, err
}
forkBranch, err := git_model.GetBranch(ctx, repo.ID, branch)
if err != nil {
return nil, err
}
baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch)
if err != nil {
return nil, err
}
info := &UpstreamDivergingInfo{}
if forkBranch.CommitID == baseBranch.CommitID {
return info, nil
}
// TODO: if the fork repo has new commits, this call will fail:
// exit status 128 - fatal: Invalid symmetric difference expression aaaaaaaaaaaa...bbbbbbbbbbbb
// so at the moment, we are not able to handle this case, should be improved in the future
diff, err := git.GetDivergingCommits(ctx, repo.BaseRepo.RepoPath(), baseBranch.CommitID, forkBranch.CommitID)
if err != nil {
info.BaseIsNewer = baseBranch.UpdatedUnix > forkBranch.UpdatedUnix
return info, nil
}
info.CommitsBehind, info.CommitsAhead = diff.Behind, diff.Ahead
return info, nil
}

View File

@ -0,0 +1,18 @@
{{if and .UpstreamDivergingInfo (or .UpstreamDivergingInfo.BaseIsNewer .UpstreamDivergingInfo.CommitsBehind)}}
<div class="ui message flex-text-block">
<div class="tw-flex-1">
{{$upstreamLink := printf "%s/src/branch/%s" .Repository.BaseRepo.Link (.BranchName|PathEscapeSegments)}}
{{$upstreamHtml := HTMLFormat `<a href="%s">%s:%s</a>` $upstreamLink .Repository.BaseRepo.FullName .BranchName}}
{{if .UpstreamDivergingInfo.CommitsBehind}}
{{ctx.Locale.TrN .UpstreamDivergingInfo.CommitsBehind "repo.pulls.upstream_diverging_prompt_behind_1" "repo.pulls.upstream_diverging_prompt_behind_n" .UpstreamDivergingInfo.CommitsBehind $upstreamHtml}}
{{else}}
{{ctx.Locale.Tr "repo.pulls.upstream_diverging_prompt_base_newer" $upstreamHtml}}
{{end}}
</div>
{{if .CanWriteCode}}
<button class="ui compact green button tw-m-0 link-action" data-url="{{.Repository.Link}}/branches/merge-upstream?branch={{.BranchName}}">
{{ctx.Locale.Tr "repo.pulls.upstream_diverging_merge"}}
</button>
{{end}}
</div>
{{end}}

View File

@ -136,6 +136,9 @@
{{else if .IsBlame}}
{{template "repo/blame" .}}
{{else}}{{/* IsViewDirectory */}}
{{if $isTreePathRoot}}
{{template "repo/code/upstream_diverging_info" .}}
{{end}}
{{template "repo/view_list" .}}
{{end}}
</div>