Skip to content

Commit 7e7ac0d

Browse files
committed
Add reparent option to Repository CreateFork API
Normally, a fork becomes the `child` of the source repository. In this case, the fork becomes the new `parent` of the source repository. Closes: #34848
1 parent 38ad585 commit 7e7ac0d

File tree

6 files changed

+125
-2
lines changed

6 files changed

+125
-2
lines changed

models/repo/fork.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,14 @@ func GetForksByUserAndOrgs(ctx context.Context, user *user_model.User, repo *Rep
101101
repoList = append(repoList, orgForks...)
102102
return repoList, nil
103103
}
104+
105+
// ReparentFork sets the fork to be an unforked repository and the forked repo becomes its fork
106+
func ReparentFork(ctx context.Context, forkedRepoID, srcForkID int64) error {
107+
if _, err := db.GetEngine(ctx).Table("repository").ID(srcForkID).Cols("fork_id", "is_fork").Update(&Repository{ForkID: forkedRepoID, IsFork: true}); err != nil {
108+
return err
109+
}
110+
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 {
111+
return err
112+
}
113+
return nil
114+
}

modules/structs/fork.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ type CreateForkOption struct {
99
Organization *string `json:"organization"`
1010
// name of the forked repository
1111
Name *string `json:"name"`
12+
// set the target fork as the parent of the source repository
13+
Reparent bool `json:"reparent"`
1214
}

routers/api/v1/repo/fork.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,32 @@ func CreateFork(ctx *context.APIContext) {
133133
return
134134
}
135135
if !ctx.Doer.IsAdmin {
136+
if form.Reparent {
137+
// we need to have owner rights in source and target to use reparent option
138+
err := repo.LoadOwner(ctx)
139+
if err != nil {
140+
ctx.APIErrorInternal(err)
141+
return
142+
}
143+
if repo.Owner.IsOrganization() {
144+
srcOrg, err := organization.GetOrgByID(ctx, repo.OwnerID)
145+
if err != nil {
146+
ctx.APIErrorInternal(err)
147+
return
148+
}
149+
isAdminForSrc, err := srcOrg.IsOrgAdmin(ctx, ctx.Doer.ID)
150+
if err != nil {
151+
ctx.APIErrorInternal(err)
152+
return
153+
}
154+
if !isAdminForSrc {
155+
ctx.APIError(http.StatusForbidden, fmt.Sprintf("User '%s' is not an Admin of the Organization '%s'", ctx.Doer.Name, srcOrg.Name))
156+
return
157+
}
158+
} else if repo.OwnerID != ctx.Doer.ID {
159+
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))
160+
}
161+
}
136162
isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID)
137163
if err != nil {
138164
ctx.APIErrorInternal(err)
@@ -156,6 +182,7 @@ func CreateFork(ctx *context.APIContext) {
156182
BaseRepo: repo,
157183
Name: name,
158184
Description: repo.Description,
185+
Reparent: form.Reparent,
159186
})
160187
if err != nil {
161188
if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) {

services/repository/fork.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type ForkRepoOptions struct {
5252
Name string
5353
Description string
5454
SingleBranch string
55+
Reparent bool
5556
}
5657

5758
// ForkRepository forks a repository
@@ -108,8 +109,19 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
108109
if err = createRepositoryInDB(ctx, doer, owner, repo, true); err != nil {
109110
return err
110111
}
111-
if err = repo_model.IncrementRepoForkNum(ctx, opts.BaseRepo.ID); err != nil {
112-
return err
112+
113+
// swap fork_id, if we reparent
114+
if opts.Reparent {
115+
if err = repo_model.ReparentFork(ctx, repo.ID, opts.BaseRepo.ID); err != nil {
116+
return err
117+
}
118+
if err = repo_model.IncrementRepoForkNum(ctx, repo.ID); err != nil {
119+
return err
120+
}
121+
} else {
122+
if err = repo_model.IncrementRepoForkNum(ctx, opts.BaseRepo.ID); err != nil {
123+
return err
124+
}
113125
}
114126

115127
// copy lfs files failure should not be ignored

templates/swagger/v1_json.tmpl

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/integration/repo_fork_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import (
77
"fmt"
88
"net/http"
99
"net/http/httptest"
10+
"path"
1011
"strconv"
1112
"testing"
1213

14+
"code.gitea.io/gitea/models/auth"
1315
org_model "code.gitea.io/gitea/models/organization"
16+
"code.gitea.io/gitea/models/repo"
1417
"code.gitea.io/gitea/models/unittest"
1518
user_model "code.gitea.io/gitea/models/user"
1619
"code.gitea.io/gitea/modules/structs"
@@ -129,3 +132,66 @@ func TestForkListLimitedAndPrivateRepos(t *testing.T) {
129132
assert.Equal(t, 2, htmlDoc.Find(forkItemSelector).Length())
130133
})
131134
}
135+
136+
func TestAPICreateForkWithReparent(t *testing.T) {
137+
defer tests.PrepareTestEnv(t)()
138+
139+
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
140+
source := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
141+
142+
session := loginUser(t, u.Name)
143+
token := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
144+
145+
urlPath := path.Join("/api/v1/repos", source.OwnerName, source.Name, "forks")
146+
name := "reparented"
147+
req := NewRequestWithJSON(t, "POST", urlPath, &structs.CreateForkOption{
148+
Reparent: true,
149+
Name: &name,
150+
})
151+
req.Header.Add("Authorization", "token "+token)
152+
resp := session.MakeRequest(t, req, http.StatusAccepted)
153+
154+
var result structs.Repository
155+
DecodeJSON(t, resp, &result)
156+
157+
assert.Equal(t, "reparented", result.Name)
158+
159+
orig := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: source.ID})
160+
forked := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: result.ID})
161+
162+
assert.Equal(t, int64(0), forked.ForkID)
163+
assert.False(t, forked.IsFork)
164+
assert.Equal(t, forked.ID, orig.ForkID)
165+
assert.True(t, orig.IsFork)
166+
}
167+
168+
func TestAPICreateForkWithoutReparent(t *testing.T) {
169+
defer tests.PrepareTestEnv(t)()
170+
171+
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
172+
source := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
173+
174+
session := loginUser(t, u.Name)
175+
token := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
176+
177+
urlPath := path.Join("/api/v1/repos", source.OwnerName, source.Name, "forks")
178+
name := "standard"
179+
req := NewRequestWithJSON(t, "POST", urlPath, &structs.CreateForkOption{
180+
Name: &name,
181+
})
182+
req.Header.Add("Authorization", "token "+token)
183+
resp := session.MakeRequest(t, req, http.StatusAccepted)
184+
185+
var result structs.Repository
186+
DecodeJSON(t, resp, &result)
187+
188+
assert.Equal(t, "standard", result.Name)
189+
190+
orig := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: source.ID})
191+
forked := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: result.ID})
192+
193+
assert.Equal(t, source.ID, forked.ForkID)
194+
assert.True(t, forked.IsFork)
195+
assert.Equal(t, int64(0), orig.ForkID)
196+
assert.False(t, orig.IsFork)
197+
}

0 commit comments

Comments
 (0)