loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: sync forks (#2364)

This allows syncing a branch of a fork with a branch of the base repo. It looks like this:
![grafik](/attachments/4508920c-7d0b-4330-9083-e3048733e38d)
This is only possible, if the fork don't have commits that are not in the main repo.

The feature is already working, but it is missing Finetuning, a better API, translations and tests, so this is currently WIP. It is also not tested with go-git.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
- [PR](https://codeberg.org/forgejo/forgejo/pulls/2364): <!--number 2364 --><!--line 0 --><!--description c3luYyBmb3Jrcw==-->sync forks<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2364
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: JakobDev <jakobdev@gmx.de>
Co-committed-by: JakobDev <jakobdev@gmx.de>

authored by

JakobDev
JakobDev
and committed by
Earl Warren
8296a23d 3272e358

+723 -4
+5
modules/git/commit.go
··· 432 432 return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil 433 433 } 434 434 435 + // GetAllBranches returns a slice with all branches that contains this commit 436 + func (c *Commit) GetAllBranches() ([]string, error) { 437 + return c.repo.getBranches(c, -1) 438 + } 439 + 435 440 // CommitFileStatus represents status of files in a commit. 436 441 type CommitFileStatus struct { 437 442 Added []string
+18
modules/git/commit_test.go
··· 5 5 6 6 import ( 7 7 "path/filepath" 8 + "slices" 8 9 "strings" 9 10 "testing" 10 11 ··· 368 369 369 370 assert.Equal(t, testcase.renames, renames) 370 371 } 372 + } 373 + 374 + func TestGetAllBranches(t *testing.T) { 375 + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") 376 + 377 + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) 378 + require.NoError(t, err) 379 + 380 + commit, err := bareRepo1.GetCommit("95bb4d39648ee7e325106df01a621c530863a653") 381 + require.NoError(t, err) 382 + 383 + branches, err := commit.GetAllBranches() 384 + require.NoError(t, err) 385 + 386 + slices.Sort(branches) 387 + 388 + assert.Equal(t, []string{"branch1", "branch2", "master"}, branches) 371 389 } 372 390 373 391 func Test_parseSubmoduleContent(t *testing.T) {
+7 -4
modules/git/repo_commit.go
··· 444 444 445 445 func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) { 446 446 if CheckGitVersionAtLeast("2.7.0") == nil { 447 - stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)"). 448 - AddOptionFormat("--count=%d", limit). 449 - AddOptionValues("--contains", commit.ID.String(), BranchPrefix). 450 - RunStdString(&RunOpts{Dir: repo.Path}) 447 + command := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)").AddOptionValues("--contains", commit.ID.String(), BranchPrefix) 448 + 449 + if limit != -1 { 450 + command = command.AddOptionFormat("--count=%d", limit) 451 + } 452 + 453 + stdout, _, err := command.RunStdString(&RunOpts{Dir: repo.Path}) 451 454 if err != nil { 452 455 return nil, err 453 456 }
+8
modules/structs/fork.go
··· 10 10 // name of the forked repository 11 11 Name *string `json:"name"` 12 12 } 13 + 14 + // SyncForkInfo information about syncing a fork 15 + type SyncForkInfo struct { 16 + Allowed bool `json:"allowed"` 17 + ForkCommit string `json:"fork_commit"` 18 + BaseCommit string `json:"base_commit"` 19 + CommitsBehind int `json:"commits_behind"` 20 + }
+4
options/locale/locale_en-US.ini
··· 1220 1220 archive.nocomment = Commenting is not possible because the repository is archived. 1221 1221 archive.pull.noreview = This repository is archived. You cannot review pull requests. 1222 1222 1223 + sync_fork.branch_behind_one = This branch is %d commit behind %s 1224 + sync_fork.branch_behind_few = This branch is %d commits behind %s 1225 + sync_fork.button = Sync 1226 + 1223 1227 form.reach_limit_of_creation_1 = The owner has already reached the limit of %d repository. 1224 1228 form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. 1225 1229 form.name_reserved = The repository name "%s" is reserved.
+6
routers/api/v1/api.go
··· 1355 1355 m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) 1356 1356 m.Delete("", repo.DeleteAvatar) 1357 1357 }, reqAdmin(), reqToken()) 1358 + m.Group("/sync_fork", func() { 1359 + m.Get("", reqRepoReader(unit.TypeCode), repo.SyncForkDefaultInfo) 1360 + m.Post("", mustNotBeArchived, reqRepoWriter(unit.TypeCode), repo.SyncForkDefault) 1361 + m.Get("/{branch}", reqRepoReader(unit.TypeCode), repo.SyncForkBranchInfo) 1362 + m.Post("/{branch}", mustNotBeArchived, reqRepoWriter(unit.TypeCode), repo.SyncForkBranch) 1363 + }) 1358 1364 1359 1365 m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) 1360 1366 }, repoAssignment(), checkTokenPublicOnly())
+185
routers/api/v1/repo/sync_fork.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package repo 5 + 6 + import ( 7 + "net/http" 8 + 9 + git_model "forgejo.org/models/git" 10 + "forgejo.org/services/context" 11 + repo_service "forgejo.org/services/repository" 12 + ) 13 + 14 + func getSyncForkInfo(ctx *context.APIContext, branch string) { 15 + if !ctx.Repo.Repository.IsFork { 16 + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") 17 + return 18 + } 19 + 20 + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) 21 + if err != nil { 22 + if git_model.IsErrBranchNotExist(err) { 23 + ctx.NotFound(err, branch) 24 + return 25 + } 26 + 27 + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) 28 + return 29 + } 30 + 31 + ctx.JSON(http.StatusOK, syncForkInfo) 32 + } 33 + 34 + // SyncForkBranchInfo returns information about syncing the default fork branch with the base branch 35 + func SyncForkDefaultInfo(ctx *context.APIContext) { 36 + // swagger:operation GET /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefaultInfo 37 + // --- 38 + // summary: Gets information about syncing the fork default branch with the base branch 39 + // produces: 40 + // - application/json 41 + // parameters: 42 + // - name: owner 43 + // in: path 44 + // description: owner of the repo 45 + // type: string 46 + // required: true 47 + // - name: repo 48 + // in: path 49 + // description: name of the repo 50 + // type: string 51 + // required: true 52 + // responses: 53 + // "200": 54 + // "$ref": "#/responses/SyncForkInfo" 55 + // "400": 56 + // "$ref": "#/responses/error" 57 + // "404": 58 + // "$ref": "#/responses/notFound" 59 + getSyncForkInfo(ctx, ctx.Repo.Repository.DefaultBranch) 60 + } 61 + 62 + // SyncForkBranchInfo returns information about syncing a fork branch with the base branch 63 + func SyncForkBranchInfo(ctx *context.APIContext) { 64 + // swagger:operation GET /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranchInfo 65 + // --- 66 + // summary: Gets information about syncing a fork branch with the base branch 67 + // produces: 68 + // - application/json 69 + // parameters: 70 + // - name: owner 71 + // in: path 72 + // description: owner of the repo 73 + // type: string 74 + // required: true 75 + // - name: repo 76 + // in: path 77 + // description: name of the repo 78 + // type: string 79 + // required: true 80 + // - name: branch 81 + // in: path 82 + // description: The branch 83 + // type: string 84 + // required: true 85 + // responses: 86 + // "200": 87 + // "$ref": "#/responses/SyncForkInfo" 88 + // "400": 89 + // "$ref": "#/responses/error" 90 + // "404": 91 + // "$ref": "#/responses/notFound" 92 + getSyncForkInfo(ctx, ctx.Params("branch")) 93 + } 94 + 95 + func syncForkBranch(ctx *context.APIContext, branch string) { 96 + if !ctx.Repo.Repository.IsFork { 97 + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") 98 + return 99 + } 100 + 101 + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) 102 + if err != nil { 103 + if git_model.IsErrBranchNotExist(err) { 104 + ctx.NotFound(err, branch) 105 + return 106 + } 107 + 108 + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) 109 + return 110 + } 111 + 112 + if !syncForkInfo.Allowed { 113 + ctx.Error(http.StatusBadRequest, "NotAllowed", "You can't sync this branch") 114 + return 115 + } 116 + 117 + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) 118 + if err != nil { 119 + ctx.Error(http.StatusInternalServerError, "SyncFork", err) 120 + return 121 + } 122 + 123 + ctx.Status(http.StatusNoContent) 124 + } 125 + 126 + // SyncForkBranch syncs the default of a fork with the base branch 127 + func SyncForkDefault(ctx *context.APIContext) { 128 + // swagger:operation POST /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefault 129 + // --- 130 + // summary: Syncs the default branch of a fork with the base branch 131 + // produces: 132 + // - application/json 133 + // parameters: 134 + // - name: owner 135 + // in: path 136 + // description: owner of the repo 137 + // type: string 138 + // required: true 139 + // - name: repo 140 + // in: path 141 + // description: name of the repo 142 + // type: string 143 + // required: true 144 + // responses: 145 + // "204": 146 + // "$ref": "#/responses/empty" 147 + // "400": 148 + // "$ref": "#/responses/error" 149 + // "404": 150 + // "$ref": "#/responses/notFound" 151 + syncForkBranch(ctx, ctx.Repo.Repository.DefaultBranch) 152 + } 153 + 154 + // SyncForkBranch syncs a fork branch with the base branch 155 + func SyncForkBranch(ctx *context.APIContext) { 156 + // swagger:operation POST /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranch 157 + // --- 158 + // summary: Syncs a fork branch with the base branch 159 + // produces: 160 + // - application/json 161 + // parameters: 162 + // - name: owner 163 + // in: path 164 + // description: owner of the repo 165 + // type: string 166 + // required: true 167 + // - name: repo 168 + // in: path 169 + // description: name of the repo 170 + // type: string 171 + // required: true 172 + // - name: branch 173 + // in: path 174 + // description: The branch 175 + // type: string 176 + // required: true 177 + // responses: 178 + // "204": 179 + // "$ref": "#/responses/empty" 180 + // "400": 181 + // "$ref": "#/responses/error" 182 + // "404": 183 + // "$ref": "#/responses/notFound" 184 + syncForkBranch(ctx, ctx.Params("branch")) 185 + }
+7
routers/api/v1/swagger/repo.go
··· 448 448 // in:body 449 449 Body api.Compare `json:"body"` 450 450 } 451 + 452 + // SyncForkInfo 453 + // swagger:response SyncForkInfo 454 + type swaggerSyncForkInfo struct { 455 + // in:body 456 + Body []api.SyncForkInfo `json:"body"` 457 + }
+24
routers/web/repo/repo.go
··· 782 782 } 783 783 ctx.Data["Branches"] = brs 784 784 } 785 + 786 + func SyncFork(ctx *context.Context) { 787 + redirectURL := fmt.Sprintf("%s/src/branch/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) 788 + branch := ctx.Params("branch") 789 + 790 + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) 791 + if err != nil { 792 + ctx.ServerError("GetSyncForkInfo", err) 793 + return 794 + } 795 + 796 + if !syncForkInfo.Allowed { 797 + ctx.Redirect(redirectURL) 798 + return 799 + } 800 + 801 + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) 802 + if err != nil { 803 + ctx.ServerError("SyncFork", err) 804 + return 805 + } 806 + 807 + ctx.Redirect(redirectURL) 808 + }
+16
routers/web/repo/view.go
··· 52 52 "forgejo.org/routers/web/feed" 53 53 "forgejo.org/services/context" 54 54 issue_service "forgejo.org/services/issue" 55 + repo_service "forgejo.org/services/repository" 55 56 files_service "forgejo.org/services/repository/files" 56 57 57 58 "github.com/nektos/act/pkg/model" ··· 1151 1152 ctx.Data["HasParentPath"] = true 1152 1153 if len(paths)-2 >= 0 { 1153 1154 ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] 1155 + } 1156 + } 1157 + 1158 + if ctx.Repo.Repository.IsFork && ctx.Repo.IsViewBranch && len(ctx.Repo.TreePath) == 0 && ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { 1159 + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName) 1160 + if err != nil { 1161 + ctx.ServerError("CanSync", err) 1162 + return 1163 + } 1164 + 1165 + if syncForkInfo.Allowed { 1166 + ctx.Data["CanSyncFork"] = true 1167 + ctx.Data["ForkCommitsBehind"] = syncForkInfo.CommitsBehind 1168 + ctx.Data["SyncForkLink"] = fmt.Sprintf("%s/sync_fork/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) 1169 + ctx.Data["BaseBranchLink"] = fmt.Sprintf("%s/src/branch/%s", ctx.Repo.Repository.BaseRepo.HTMLURL(), util.PathEscapeSegments(ctx.Repo.BranchName)) 1154 1170 } 1155 1171 } 1156 1172
+2
routers/web/web.go
··· 1592 1592 }, context.RepoRef(), reqRepoCodeReader) 1593 1593 } 1594 1594 m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) 1595 + 1596 + m.Get("/sync_fork/{branch}", context.RepoMustNotBeArchived(), repo.MustBeNotEmpty, reqRepoCodeWriter, repo.SyncFork) 1595 1597 }, ignSignIn, context.RepoAssignment, context.UnitTypes()) 1596 1598 1597 1599 m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit)
+113
services/repository/sync_fork.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package repository 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + "slices" 10 + 11 + git_model "forgejo.org/models/git" 12 + repo_model "forgejo.org/models/repo" 13 + user_model "forgejo.org/models/user" 14 + "forgejo.org/modules/git" 15 + repo_module "forgejo.org/modules/repository" 16 + api "forgejo.org/modules/structs" 17 + ) 18 + 19 + // SyncFork syncs a branch of a fork with the base repo 20 + func SyncFork(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) error { 21 + err := repo.MustNotBeArchived() 22 + if err != nil { 23 + return err 24 + } 25 + 26 + err = repo.GetBaseRepo(ctx) 27 + if err != nil { 28 + return err 29 + } 30 + 31 + err = git.Push(ctx, repo.BaseRepo.RepoPath(), git.PushOptions{ 32 + Remote: repo.RepoPath(), 33 + Branch: fmt.Sprintf("%s:%s", branch, branch), 34 + Env: repo_module.PushingEnvironment(doer, repo), 35 + }) 36 + 37 + return err 38 + } 39 + 40 + // CanSyncFork returns information about syncing a fork 41 + func GetSyncForkInfo(ctx context.Context, repo *repo_model.Repository, branch string) (*api.SyncForkInfo, error) { 42 + info := new(api.SyncForkInfo) 43 + 44 + if !repo.IsFork { 45 + return info, nil 46 + } 47 + 48 + if repo.IsArchived { 49 + return info, nil 50 + } 51 + 52 + err := repo.GetBaseRepo(ctx) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + forkBranch, err := git_model.GetBranch(ctx, repo.ID, branch) 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + info.ForkCommit = forkBranch.CommitID 63 + 64 + baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch) 65 + if err != nil { 66 + if git_model.IsErrBranchNotExist(err) { 67 + // If the base repo don't have the branch, we don't need to continue 68 + return info, nil 69 + } 70 + return nil, err 71 + } 72 + 73 + info.BaseCommit = baseBranch.CommitID 74 + 75 + // If both branches has the same latest commit, we don't need to sync 76 + if forkBranch.CommitID == baseBranch.CommitID { 77 + return info, nil 78 + } 79 + 80 + // Check if the latest commit of the fork is also in the base 81 + gitRepo, err := git.OpenRepository(ctx, repo.BaseRepo.RepoPath()) 82 + if err != nil { 83 + return nil, err 84 + } 85 + defer gitRepo.Close() 86 + 87 + commit, err := gitRepo.GetCommit(forkBranch.CommitID) 88 + if err != nil { 89 + if git.IsErrNotExist(err) { 90 + return info, nil 91 + } 92 + return nil, err 93 + } 94 + 95 + branchList, err := commit.GetAllBranches() 96 + if err != nil { 97 + return nil, err 98 + } 99 + 100 + if !slices.Contains(branchList, branch) { 101 + return info, nil 102 + } 103 + 104 + diff, err := git.GetDivergingCommits(ctx, repo.BaseRepo.RepoPath(), baseBranch.CommitID, forkBranch.CommitID) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + info.Allowed = true 110 + info.CommitsBehind = diff.Behind 111 + 112 + return info, nil 113 + }
+12
templates/repo/home.tmpl
··· 158 158 {{end}} 159 159 </div> 160 160 </div> 161 + 162 + {{if .CanSyncFork}} 163 + <div class="ui positive message tw-flex tw-items-center"> 164 + <div class="tw-flex-1"> 165 + {{ctx.Locale.TrN .ForkCommitsBehind "repo.sync_fork.branch_behind_one" "repo.sync_fork.branch_behind_few" .ForkCommitsBehind (printf "<a href='%s'>%s:%s</a>" .BaseBranchLink .Repository.BaseRepo.FullName .BranchName | SafeHTML)}} 166 + </div> 167 + <a role="button" class="ui compact positive button tw-m-0" href="{{.SyncForkLink}}"> 168 + {{ctx.Locale.Tr "repo.sync_fork.button"}} 169 + </a> 170 + </div> 171 + {{end}} 172 + 161 173 {{if .IsViewFile}} 162 174 {{template "repo/view_file" .}} 163 175 {{else if .IsBlame}}
+199
templates/swagger/v1_json.tmpl
··· 15630 15630 } 15631 15631 } 15632 15632 }, 15633 + "/repos/{owner}/{repo}/sync_fork": { 15634 + "get": { 15635 + "produces": [ 15636 + "application/json" 15637 + ], 15638 + "tags": [ 15639 + "repository" 15640 + ], 15641 + "summary": "Gets information about syncing the fork default branch with the base branch", 15642 + "operationId": "repoSyncForkDefaultInfo", 15643 + "parameters": [ 15644 + { 15645 + "type": "string", 15646 + "description": "owner of the repo", 15647 + "name": "owner", 15648 + "in": "path", 15649 + "required": true 15650 + }, 15651 + { 15652 + "type": "string", 15653 + "description": "name of the repo", 15654 + "name": "repo", 15655 + "in": "path", 15656 + "required": true 15657 + } 15658 + ], 15659 + "responses": { 15660 + "200": { 15661 + "$ref": "#/responses/SyncForkInfo" 15662 + }, 15663 + "400": { 15664 + "$ref": "#/responses/error" 15665 + }, 15666 + "404": { 15667 + "$ref": "#/responses/notFound" 15668 + } 15669 + } 15670 + }, 15671 + "post": { 15672 + "produces": [ 15673 + "application/json" 15674 + ], 15675 + "tags": [ 15676 + "repository" 15677 + ], 15678 + "summary": "Syncs the default branch of a fork with the base branch", 15679 + "operationId": "repoSyncForkDefault", 15680 + "parameters": [ 15681 + { 15682 + "type": "string", 15683 + "description": "owner of the repo", 15684 + "name": "owner", 15685 + "in": "path", 15686 + "required": true 15687 + }, 15688 + { 15689 + "type": "string", 15690 + "description": "name of the repo", 15691 + "name": "repo", 15692 + "in": "path", 15693 + "required": true 15694 + } 15695 + ], 15696 + "responses": { 15697 + "204": { 15698 + "$ref": "#/responses/empty" 15699 + }, 15700 + "400": { 15701 + "$ref": "#/responses/error" 15702 + }, 15703 + "404": { 15704 + "$ref": "#/responses/notFound" 15705 + } 15706 + } 15707 + } 15708 + }, 15709 + "/repos/{owner}/{repo}/sync_fork/{branch}": { 15710 + "get": { 15711 + "produces": [ 15712 + "application/json" 15713 + ], 15714 + "tags": [ 15715 + "repository" 15716 + ], 15717 + "summary": "Gets information about syncing a fork branch with the base branch", 15718 + "operationId": "repoSyncForkBranchInfo", 15719 + "parameters": [ 15720 + { 15721 + "type": "string", 15722 + "description": "owner of the repo", 15723 + "name": "owner", 15724 + "in": "path", 15725 + "required": true 15726 + }, 15727 + { 15728 + "type": "string", 15729 + "description": "name of the repo", 15730 + "name": "repo", 15731 + "in": "path", 15732 + "required": true 15733 + }, 15734 + { 15735 + "type": "string", 15736 + "description": "The branch", 15737 + "name": "branch", 15738 + "in": "path", 15739 + "required": true 15740 + } 15741 + ], 15742 + "responses": { 15743 + "200": { 15744 + "$ref": "#/responses/SyncForkInfo" 15745 + }, 15746 + "400": { 15747 + "$ref": "#/responses/error" 15748 + }, 15749 + "404": { 15750 + "$ref": "#/responses/notFound" 15751 + } 15752 + } 15753 + }, 15754 + "post": { 15755 + "produces": [ 15756 + "application/json" 15757 + ], 15758 + "tags": [ 15759 + "repository" 15760 + ], 15761 + "summary": "Syncs a fork branch with the base branch", 15762 + "operationId": "repoSyncForkBranch", 15763 + "parameters": [ 15764 + { 15765 + "type": "string", 15766 + "description": "owner of the repo", 15767 + "name": "owner", 15768 + "in": "path", 15769 + "required": true 15770 + }, 15771 + { 15772 + "type": "string", 15773 + "description": "name of the repo", 15774 + "name": "repo", 15775 + "in": "path", 15776 + "required": true 15777 + }, 15778 + { 15779 + "type": "string", 15780 + "description": "The branch", 15781 + "name": "branch", 15782 + "in": "path", 15783 + "required": true 15784 + } 15785 + ], 15786 + "responses": { 15787 + "204": { 15788 + "$ref": "#/responses/empty" 15789 + }, 15790 + "400": { 15791 + "$ref": "#/responses/error" 15792 + }, 15793 + "404": { 15794 + "$ref": "#/responses/notFound" 15795 + } 15796 + } 15797 + } 15798 + }, 15633 15799 "/repos/{owner}/{repo}/tag_protections": { 15634 15800 "get": { 15635 15801 "produces": [ ··· 27432 27598 }, 27433 27599 "x-go-package": "forgejo.org/modules/structs" 27434 27600 }, 27601 + "SyncForkInfo": { 27602 + "description": "SyncForkInfo information about syncing a fork", 27603 + "type": "object", 27604 + "properties": { 27605 + "allowed": { 27606 + "type": "boolean", 27607 + "x-go-name": "Allowed" 27608 + }, 27609 + "base_commit": { 27610 + "type": "string", 27611 + "x-go-name": "BaseCommit" 27612 + }, 27613 + "commits_behind": { 27614 + "type": "integer", 27615 + "format": "int64", 27616 + "x-go-name": "CommitsBehind" 27617 + }, 27618 + "fork_commit": { 27619 + "type": "string", 27620 + "x-go-name": "ForkCommit" 27621 + } 27622 + }, 27623 + "x-go-package": "forgejo.org/modules/structs" 27624 + }, 27435 27625 "Tag": { 27436 27626 "description": "Tag represents a repository tag", 27437 27627 "type": "object", ··· 29208 29398 "type": "array", 29209 29399 "items": { 29210 29400 "type": "string" 29401 + } 29402 + } 29403 + }, 29404 + "SyncForkInfo": { 29405 + "description": "SyncForkInfo", 29406 + "schema": { 29407 + "type": "array", 29408 + "items": { 29409 + "$ref": "#/definitions/SyncForkInfo" 29211 29410 } 29212 29411 } 29213 29412 },
+117
tests/integration/repo_sync_fork_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "net/url" 10 + "testing" 11 + 12 + auth_model "forgejo.org/models/auth" 13 + repo_model "forgejo.org/models/repo" 14 + "forgejo.org/models/unittest" 15 + user_model "forgejo.org/models/user" 16 + api "forgejo.org/modules/structs" 17 + 18 + "github.com/stretchr/testify/assert" 19 + "github.com/stretchr/testify/require" 20 + ) 21 + 22 + func syncForkTest(t *testing.T, forkName, urlPart string, webSync bool) { 23 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) 24 + 25 + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) 26 + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID}) 27 + 28 + session := loginUser(t, user.Name) 29 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 30 + 31 + /// Create a new fork 32 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.LowerName), &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) 33 + MakeRequest(t, req, http.StatusAccepted) 34 + 35 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) 36 + resp := MakeRequest(t, req, http.StatusOK) 37 + 38 + var syncForkInfo *api.SyncForkInfo 39 + DecodeJSON(t, resp, &syncForkInfo) 40 + 41 + // This is a new fork, so the commits in both branches should be the same 42 + assert.False(t, syncForkInfo.Allowed) 43 + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) 44 + 45 + // Make a commit on the base branch 46 + err := createOrReplaceFileInBranch(baseUser, baseRepo, "sync_fork.txt", "master", "Hello") 47 + require.NoError(t, err) 48 + 49 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) 50 + resp = MakeRequest(t, req, http.StatusOK) 51 + 52 + DecodeJSON(t, resp, &syncForkInfo) 53 + 54 + // The commits should no longer be the same and we can sync 55 + assert.True(t, syncForkInfo.Allowed) 56 + assert.NotEqual(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) 57 + 58 + // Sync the fork 59 + if webSync { 60 + session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s/sync_fork/master", user.Name, forkName), http.StatusSeeOther) 61 + } else { 62 + req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) 63 + MakeRequest(t, req, http.StatusNoContent) 64 + } 65 + 66 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) 67 + resp = MakeRequest(t, req, http.StatusOK) 68 + 69 + DecodeJSON(t, resp, &syncForkInfo) 70 + 71 + // After the sync both commits should be the same again 72 + assert.False(t, syncForkInfo.Allowed) 73 + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) 74 + } 75 + 76 + func TestAPIRepoSyncForkDefault(t *testing.T) { 77 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 78 + syncForkTest(t, "SyncForkDefault", "sync_fork", false) 79 + }) 80 + } 81 + 82 + func TestAPIRepoSyncForkBranch(t *testing.T) { 83 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 84 + syncForkTest(t, "SyncForkBranch", "sync_fork/master", false) 85 + }) 86 + } 87 + 88 + func TestWebRepoSyncForkBranch(t *testing.T) { 89 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 90 + syncForkTest(t, "SyncForkBranch", "sync_fork/master", true) 91 + }) 92 + } 93 + 94 + func TestWebRepoSyncForkHomepage(t *testing.T) { 95 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 96 + forkName := "SyncForkHomepage" 97 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) 98 + 99 + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) 100 + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID}) 101 + 102 + session := loginUser(t, user.Name) 103 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 104 + 105 + /// Create a new fork 106 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.LowerName), &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) 107 + MakeRequest(t, req, http.StatusAccepted) 108 + 109 + // Make a commit on the base branch 110 + err := createOrReplaceFileInBranch(baseUser, baseRepo, "sync_fork.txt", "master", "Hello") 111 + require.NoError(t, err) 112 + 113 + resp := session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s", user.Name, forkName), http.StatusOK) 114 + 115 + assert.Contains(t, resp.Body.String(), fmt.Sprintf("This branch is 1 commit behind <a href='http://localhost:%s/user2/repo1/src/branch/master'>user2/repo1:master</a>", u.Port())) 116 + }) 117 + }