diff --git a/models/repo/fork.go b/models/repo/fork.go index 1c75e86458b2f..da00ccef4068f 100644 --- a/models/repo/fork.go +++ b/models/repo/fork.go @@ -101,3 +101,16 @@ func GetForksByUserAndOrgs(ctx context.Context, user *user_model.User, repo *Rep repoList = append(repoList, orgForks...) return repoList, nil } + +// ReparentFork sets the fork to be an unforked repository and the forked repo becomes its fork +func ReparentFork(ctx context.Context, forkedRepoID, srcForkID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := db.GetEngine(ctx).Table("repository").ID(srcForkID).Cols("fork_id", "is_fork").Update(&Repository{ForkID: forkedRepoID, IsFork: true}); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Table("repository").ID(forkedRepoID).Cols("fork_id", "is_fork", "num_forks").Update(&Repository{ForkID: 0, NumForks: 1, IsFork: false}); err != nil { + return err + } + return nil + }) +} diff --git a/modules/structs/fork.go b/modules/structs/fork.go index eb7774afbcb56..3a404f23b9645 100644 --- a/modules/structs/fork.go +++ b/modules/structs/fork.go @@ -9,4 +9,6 @@ type CreateForkOption struct { Organization *string `json:"organization"` // name of the forked repository Name *string `json:"name"` + // set the target fork as the parent of the source repository + Reparent bool `json:"reparent"` } diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index 58f66954e1251..48cb6a8956572 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -133,6 +133,32 @@ func CreateFork(ctx *context.APIContext) { return } if !ctx.Doer.IsAdmin { + if form.Reparent { + // we need to have owner rights in source and target to use reparent option + err := repo.LoadOwner(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if repo.Owner.IsOrganization() { + srcOrg, err := organization.GetOrgByID(ctx, repo.OwnerID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + isAdminForSrc, err := srcOrg.IsOrgAdmin(ctx, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !isAdminForSrc { + ctx.APIError(http.StatusForbidden, fmt.Sprintf("User '%s' is not an Admin of the Organization '%s'", ctx.Doer.Name, srcOrg.Name)) + return + } + } else if repo.OwnerID != ctx.Doer.ID { + ctx.APIError(http.StatusForbidden, fmt.Sprintf("User '%s' is not the owner of the source repository and repository is in user space", ctx.Doer.Name)) + } + } isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.APIErrorInternal(err) @@ -156,6 +182,7 @@ func CreateFork(ctx *context.APIContext) { BaseRepo: repo, Name: name, Description: repo.Description, + Reparent: form.Reparent, }) if err != nil { if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { diff --git a/services/repository/fork.go b/services/repository/fork.go index 8bd3498b1715c..3f487563a85e7 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -52,6 +52,7 @@ type ForkRepoOptions struct { Name string Description string SingleBranch string + Reparent bool } // ForkRepository forks a repository @@ -108,8 +109,16 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork if err = createRepositoryInDB(ctx, doer, owner, repo, true); err != nil { return err } - if err = repo_model.IncrementRepoForkNum(ctx, opts.BaseRepo.ID); err != nil { - return err + + // swap fork_id, if we reparent + if opts.Reparent { + if err = repo_model.ReparentFork(ctx, repo.ID, opts.BaseRepo.ID); err != nil { + return err + } + } else { + if err = repo_model.IncrementRepoForkNum(ctx, opts.BaseRepo.ID); err != nil { + return err + } } // copy lfs files failure should not be ignored diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 59da3ae9be744..c619de908569f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22880,6 +22880,11 @@ "description": "organization name, if forking into an organization", "type": "string", "x-go-name": "Organization" + }, + "reparent": { + "description": "set the target fork as the parent of the source repository", + "type": "boolean", + "x-go-name": "Reparent" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" diff --git a/tests/integration/repo_fork_test.go b/tests/integration/repo_fork_test.go index e24f31adf2e62..40ca1996895c4 100644 --- a/tests/integration/repo_fork_test.go +++ b/tests/integration/repo_fork_test.go @@ -7,10 +7,13 @@ import ( "fmt" "net/http" "net/http/httptest" + "path" "strconv" "testing" + "code.gitea.io/gitea/models/auth" org_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/structs" @@ -129,3 +132,70 @@ func TestForkListLimitedAndPrivateRepos(t *testing.T) { assert.Equal(t, 2, htmlDoc.Find(forkItemSelector).Length()) }) } + +func TestAPICreateForkWithReparent(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + source := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + session := loginUser(t, u.Name) + token := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository) + + urlPath := path.Join("/api/v1/repos", source.OwnerName, source.Name, "forks") + name := "reparented" + req := NewRequestWithJSON(t, "POST", urlPath, &structs.CreateForkOption{ + Reparent: true, + Name: &name, + }) + req.Header.Add("Authorization", "token "+token) + resp := session.MakeRequest(t, req, http.StatusAccepted) + + var result structs.Repository + DecodeJSON(t, resp, &result) + + assert.Equal(t, "reparented", result.Name) + + orig := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: source.ID}) + forked := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: result.ID}) + + assert.Equal(t, int64(0), forked.ForkID) + assert.False(t, forked.IsFork) + assert.Equal(t, forked.ID, orig.ForkID) + assert.True(t, orig.IsFork) + assert.Equal(t, 1, forked.NumForks) + assert.Equal(t, 0, orig.NumForks) +} + +func TestAPICreateForkWithoutReparent(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + source := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + session := loginUser(t, u.Name) + token := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository) + + urlPath := path.Join("/api/v1/repos", source.OwnerName, source.Name, "forks") + name := "standard" + req := NewRequestWithJSON(t, "POST", urlPath, &structs.CreateForkOption{ + Name: &name, + }) + req.Header.Add("Authorization", "token "+token) + resp := session.MakeRequest(t, req, http.StatusAccepted) + + var result structs.Repository + DecodeJSON(t, resp, &result) + + assert.Equal(t, "standard", result.Name) + + orig := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: source.ID}) + forked := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: result.ID}) + + assert.Equal(t, source.ID, forked.ForkID) + assert.True(t, forked.IsFork) + assert.Equal(t, int64(0), orig.ForkID) + assert.False(t, orig.IsFork) + assert.Equal(t, 0, forked.NumForks) + assert.Equal(t, 1, orig.NumForks) +}