Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions models/repo/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fork numbers should be updated as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the source repo, the number of forks remain as-is. Only its new parent is updated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fork count of the source repository should be decreased, while the fork count of the new parent repository should be increased.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fork count in the source repository is not yet increased here and it never is because it's part of the else{} clause in the calling function.

The fork of the new parent is set as 1 already.

But I understand how this design is a little confusing. I'll split this into separate API call instead which will make this more straightforward.

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
})
}
2 changes: 2 additions & 0 deletions modules/structs/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
27 changes: 27 additions & 0 deletions routers/api/v1/repo/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
13 changes: 11 additions & 2 deletions services/repository/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type ForkRepoOptions struct {
Name string
Description string
SingleBranch string
Reparent bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the previous implementation to have a new API to do the reparent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous implementation? Or do you mean this should be a new API call, like Repository/CreateNewParent or similar name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do a new API call here, can we still use the repo_service.ForkRepoOptions{Reparent:true} or split off that logic on top of a fork?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think introducing a new API endpoint would be more appropriate. Also, the Reparent field in ForkRepoOptions feels a bit awkward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we can use the internal Fork implementation already, I'm ok in introducing another API.

}

// ForkRepository forks a repository
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions templates/swagger/v1_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions tests/integration/repo_fork_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}