diff --git a/modules/git/commit.go b/modules/git/commit.go index 816bd051f6..a8ebed4968 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -432,6 +432,11 @@ func (c *Commit) GetBranchName() (string, error) { return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil } +// GetAllBranches returns a slice with all branches that contains this commit +func (c *Commit) GetAllBranches() ([]string, error) { + return c.repo.getBranches(c, -1) +} + // CommitFileStatus represents status of files in a commit. type CommitFileStatus struct { Added []string diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index 0acd5ac446..bc7d99658e 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -5,6 +5,7 @@ package git import ( "path/filepath" + "slices" "strings" "testing" @@ -370,6 +371,23 @@ func TestParseCommitRenames(t *testing.T) { } } +func TestGetAllBranches(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + + commit, err := bareRepo1.GetCommit("95bb4d39648ee7e325106df01a621c530863a653") + require.NoError(t, err) + + branches, err := commit.GetAllBranches() + require.NoError(t, err) + + slices.Sort(branches) + + assert.Equal(t, []string{"branch1", "branch2", "master"}, branches) +} + func Test_parseSubmoduleContent(t *testing.T) { submoduleFiles := []struct { fileContent string diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 65ab6fd3fd..4c8516f828 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -444,10 +444,13 @@ func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) { if CheckGitVersionAtLeast("2.7.0") == nil { - stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)"). - AddOptionFormat("--count=%d", limit). - AddOptionValues("--contains", commit.ID.String(), BranchPrefix). - RunStdString(&RunOpts{Dir: repo.Path}) + command := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)").AddOptionValues("--contains", commit.ID.String(), BranchPrefix) + + if limit != -1 { + command = command.AddOptionFormat("--count=%d", limit) + } + + stdout, _, err := command.RunStdString(&RunOpts{Dir: repo.Path}) if err != nil { return nil, err } diff --git a/modules/structs/fork.go b/modules/structs/fork.go index eb7774afbc..8fc73647bd 100644 --- a/modules/structs/fork.go +++ b/modules/structs/fork.go @@ -10,3 +10,11 @@ type CreateForkOption struct { // name of the forked repository Name *string `json:"name"` } + +// SyncForkInfo information about syncing a fork +type SyncForkInfo struct { + Allowed bool `json:"allowed"` + ForkCommit string `json:"fork_commit"` + BaseCommit string `json:"base_commit"` + CommitsBehind int `json:"commits_behind"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 7a70e16ca8..48de439f6a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1220,6 +1220,10 @@ archive.title_date = This repository has been archived on %s. You can view files archive.nocomment = Commenting is not possible because the repository is archived. archive.pull.noreview = This repository is archived. You cannot review pull requests. +sync_fork.branch_behind_one = This branch is %d commit behind %s +sync_fork.branch_behind_few = This branch is %d commits behind %s +sync_fork.button = Sync + form.reach_limit_of_creation_1 = The owner has already reached the limit of %d repository. form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. form.name_reserved = The repository name "%s" is reserved. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 551fcf7a43..1c6f260b23 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1355,6 +1355,12 @@ func Routes() *web.Route { m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) + m.Group("/sync_fork", func() { + m.Get("", reqRepoReader(unit.TypeCode), repo.SyncForkDefaultInfo) + m.Post("", mustNotBeArchived, reqRepoWriter(unit.TypeCode), repo.SyncForkDefault) + m.Get("/{branch}", reqRepoReader(unit.TypeCode), repo.SyncForkBranchInfo) + m.Post("/{branch}", mustNotBeArchived, reqRepoWriter(unit.TypeCode), repo.SyncForkBranch) + }) m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/sync_fork.go b/routers/api/v1/repo/sync_fork.go new file mode 100644 index 0000000000..c3a9bd26ba --- /dev/null +++ b/routers/api/v1/repo/sync_fork.go @@ -0,0 +1,185 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + git_model "forgejo.org/models/git" + "forgejo.org/services/context" + repo_service "forgejo.org/services/repository" +) + +func getSyncForkInfo(ctx *context.APIContext, branch string) { + if !ctx.Repo.Repository.IsFork { + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") + return + } + + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + ctx.NotFound(err, branch) + return + } + + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) + return + } + + ctx.JSON(http.StatusOK, syncForkInfo) +} + +// SyncForkBranchInfo returns information about syncing the default fork branch with the base branch +func SyncForkDefaultInfo(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefaultInfo + // --- + // summary: Gets information about syncing the fork default branch with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/SyncForkInfo" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + getSyncForkInfo(ctx, ctx.Repo.Repository.DefaultBranch) +} + +// SyncForkBranchInfo returns information about syncing a fork branch with the base branch +func SyncForkBranchInfo(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranchInfo + // --- + // summary: Gets information about syncing a fork branch with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: branch + // in: path + // description: The branch + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/SyncForkInfo" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + getSyncForkInfo(ctx, ctx.Params("branch")) +} + +func syncForkBranch(ctx *context.APIContext, branch string) { + if !ctx.Repo.Repository.IsFork { + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") + return + } + + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + ctx.NotFound(err, branch) + return + } + + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) + return + } + + if !syncForkInfo.Allowed { + ctx.Error(http.StatusBadRequest, "NotAllowed", "You can't sync this branch") + return + } + + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SyncFork", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// SyncForkBranch syncs the default of a fork with the base branch +func SyncForkDefault(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefault + // --- + // summary: Syncs the default branch of a fork with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + syncForkBranch(ctx, ctx.Repo.Repository.DefaultBranch) +} + +// SyncForkBranch syncs a fork branch with the base branch +func SyncForkBranch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranch + // --- + // summary: Syncs a fork branch with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: branch + // in: path + // description: The branch + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + syncForkBranch(ctx, ctx.Params("branch")) +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 445e3417fb..bf7e2cc0c3 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -448,3 +448,10 @@ type swaggerCompare struct { // in:body Body api.Compare `json:"body"` } + +// SyncForkInfo +// swagger:response SyncForkInfo +type swaggerSyncForkInfo struct { + // in:body + Body []api.SyncForkInfo `json:"body"` +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 53b3f34347..226c666c64 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -782,3 +782,27 @@ func PrepareBranchList(ctx *context.Context) { } ctx.Data["Branches"] = brs } + +func SyncFork(ctx *context.Context) { + redirectURL := fmt.Sprintf("%s/src/branch/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) + branch := ctx.Params("branch") + + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) + if err != nil { + ctx.ServerError("GetSyncForkInfo", err) + return + } + + if !syncForkInfo.Allowed { + ctx.Redirect(redirectURL) + return + } + + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + if err != nil { + ctx.ServerError("SyncFork", err) + return + } + + ctx.Redirect(redirectURL) +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index bea002f690..dcbf665f0f 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -52,6 +52,7 @@ import ( "forgejo.org/routers/web/feed" "forgejo.org/services/context" issue_service "forgejo.org/services/issue" + repo_service "forgejo.org/services/repository" files_service "forgejo.org/services/repository/files" "github.com/nektos/act/pkg/model" @@ -1154,6 +1155,21 @@ PostRecentBranchCheck: } } + if ctx.Repo.Repository.IsFork && ctx.Repo.IsViewBranch && len(ctx.Repo.TreePath) == 0 && ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName) + if err != nil { + ctx.ServerError("CanSync", err) + return + } + + if syncForkInfo.Allowed { + ctx.Data["CanSyncFork"] = true + ctx.Data["ForkCommitsBehind"] = syncForkInfo.CommitsBehind + ctx.Data["SyncForkLink"] = fmt.Sprintf("%s/sync_fork/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) + ctx.Data["BaseBranchLink"] = fmt.Sprintf("%s/src/branch/%s", ctx.Repo.Repository.BaseRepo.HTMLURL(), util.PathEscapeSegments(ctx.Repo.BranchName)) + } + } + ctx.Data["Paths"] = paths branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() diff --git a/routers/web/web.go b/routers/web/web.go index 58aa5d8766..443a998248 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1592,6 +1592,8 @@ func registerRoutes(m *web.Route) { }, context.RepoRef(), reqRepoCodeReader) } m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + + m.Get("/sync_fork/{branch}", context.RepoMustNotBeArchived(), repo.MustBeNotEmpty, reqRepoCodeWriter, repo.SyncFork) }, ignSignIn, context.RepoAssignment, context.UnitTypes()) m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit) diff --git a/services/repository/sync_fork.go b/services/repository/sync_fork.go new file mode 100644 index 0000000000..99e7c33781 --- /dev/null +++ b/services/repository/sync_fork.go @@ -0,0 +1,113 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "fmt" + "slices" + + git_model "forgejo.org/models/git" + repo_model "forgejo.org/models/repo" + user_model "forgejo.org/models/user" + "forgejo.org/modules/git" + repo_module "forgejo.org/modules/repository" + api "forgejo.org/modules/structs" +) + +// SyncFork syncs a branch of a fork with the base repo +func SyncFork(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) error { + err := repo.MustNotBeArchived() + if err != nil { + return err + } + + err = repo.GetBaseRepo(ctx) + if 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), + }) + + return err +} + +// CanSyncFork returns information about syncing a fork +func GetSyncForkInfo(ctx context.Context, repo *repo_model.Repository, branch string) (*api.SyncForkInfo, error) { + info := new(api.SyncForkInfo) + + if !repo.IsFork { + return info, nil + } + + if repo.IsArchived { + return info, nil + } + + err := repo.GetBaseRepo(ctx) + if err != nil { + return nil, err + } + + forkBranch, err := git_model.GetBranch(ctx, repo.ID, branch) + if err != nil { + return nil, err + } + + info.ForkCommit = forkBranch.CommitID + + baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + // If the base repo don't have the branch, we don't need to continue + return info, nil + } + return nil, err + } + + info.BaseCommit = baseBranch.CommitID + + // If both branches has the same latest commit, we don't need to sync + if forkBranch.CommitID == baseBranch.CommitID { + return info, nil + } + + // Check if the latest commit of the fork is also in the base + gitRepo, err := git.OpenRepository(ctx, repo.BaseRepo.RepoPath()) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(forkBranch.CommitID) + if err != nil { + if git.IsErrNotExist(err) { + return info, nil + } + return nil, err + } + + branchList, err := commit.GetAllBranches() + if err != nil { + return nil, err + } + + if !slices.Contains(branchList, branch) { + return info, nil + } + + diff, err := git.GetDivergingCommits(ctx, repo.BaseRepo.RepoPath(), baseBranch.CommitID, forkBranch.CommitID) + if err != nil { + return nil, err + } + + info.Allowed = true + info.CommitsBehind = diff.Behind + + return info, nil +} diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index d1685f6a79..318630dfbd 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -158,6 +158,18 @@ {{end}} + + {{if .CanSyncFork}} +
+
+ {{ctx.Locale.TrN .ForkCommitsBehind "repo.sync_fork.branch_behind_one" "repo.sync_fork.branch_behind_few" .ForkCommitsBehind (printf "%s:%s" .BaseBranchLink .Repository.BaseRepo.FullName .BranchName | SafeHTML)}} +
+ + {{ctx.Locale.Tr "repo.sync_fork.button"}} + +
+ {{end}} + {{if .IsViewFile}} {{template "repo/view_file" .}} {{else if .IsBlame}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a16deb61a8..97529248e9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -15630,6 +15630,172 @@ } } }, + "/repos/{owner}/{repo}/sync_fork": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets information about syncing the fork default branch with the base branch", + "operationId": "repoSyncForkDefaultInfo", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/SyncForkInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Syncs the default branch of a fork with the base branch", + "operationId": "repoSyncForkDefault", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/sync_fork/{branch}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets information about syncing a fork branch with the base branch", + "operationId": "repoSyncForkBranchInfo", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The branch", + "name": "branch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/SyncForkInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Syncs a fork branch with the base branch", + "operationId": "repoSyncForkBranch", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The branch", + "name": "branch", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/tag_protections": { "get": { "produces": [ @@ -27432,6 +27598,30 @@ }, "x-go-package": "forgejo.org/modules/structs" }, + "SyncForkInfo": { + "description": "SyncForkInfo information about syncing a fork", + "type": "object", + "properties": { + "allowed": { + "type": "boolean", + "x-go-name": "Allowed" + }, + "base_commit": { + "type": "string", + "x-go-name": "BaseCommit" + }, + "commits_behind": { + "type": "integer", + "format": "int64", + "x-go-name": "CommitsBehind" + }, + "fork_commit": { + "type": "string", + "x-go-name": "ForkCommit" + } + }, + "x-go-package": "forgejo.org/modules/structs" + }, "Tag": { "description": "Tag represents a repository tag", "type": "object", @@ -29211,6 +29401,15 @@ } } }, + "SyncForkInfo": { + "description": "SyncForkInfo", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/SyncForkInfo" + } + } + }, "Tag": { "description": "Tag", "schema": { diff --git a/tests/integration/repo_sync_fork_test.go b/tests/integration/repo_sync_fork_test.go new file mode 100644 index 0000000000..956494cfc6 --- /dev/null +++ b/tests/integration/repo_sync_fork_test.go @@ -0,0 +1,117 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "forgejo.org/models/auth" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + api "forgejo.org/modules/structs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func syncForkTest(t *testing.T, forkName, urlPart string, webSync bool) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + /// Create a new fork + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.LowerName), &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var syncForkInfo *api.SyncForkInfo + DecodeJSON(t, resp, &syncForkInfo) + + // This is a new fork, so the commits in both branches should be the same + assert.False(t, syncForkInfo.Allowed) + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) + + // Make a commit on the base branch + err := createOrReplaceFileInBranch(baseUser, baseRepo, "sync_fork.txt", "master", "Hello") + require.NoError(t, err) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &syncForkInfo) + + // The commits should no longer be the same and we can sync + assert.True(t, syncForkInfo.Allowed) + assert.NotEqual(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) + + // Sync the fork + if webSync { + session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s/sync_fork/master", user.Name, forkName), http.StatusSeeOther) + } else { + req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &syncForkInfo) + + // After the sync both commits should be the same again + assert.False(t, syncForkInfo.Allowed) + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) +} + +func TestAPIRepoSyncForkDefault(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + syncForkTest(t, "SyncForkDefault", "sync_fork", false) + }) +} + +func TestAPIRepoSyncForkBranch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + syncForkTest(t, "SyncForkBranch", "sync_fork/master", false) + }) +} + +func TestWebRepoSyncForkBranch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + syncForkTest(t, "SyncForkBranch", "sync_fork/master", true) + }) +} + +func TestWebRepoSyncForkHomepage(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + forkName := "SyncForkHomepage" + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + /// Create a new fork + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.LowerName), &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + // Make a commit on the base branch + err := createOrReplaceFileInBranch(baseUser, baseRepo, "sync_fork.txt", "master", "Hello") + require.NoError(t, err) + + resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s", user.Name, forkName), http.StatusOK) + + assert.Contains(t, resp.Body.String(), fmt.Sprintf("This branch is 1 commit behind user2/repo1:master", u.Port())) + }) +}