Adopt repositories (#12920)

* Don't automatically delete repository files if they are present

Prior to this PR Gitea would delete any repository files if they are
present during creation or migration. This can in certain circumstances
lead to data-loss and is slightly unpleasant.

This PR provides a mechanism for Gitea to adopt repositories on creation
and otherwise requires an explicit flag for deletion.

PushCreate is slightly different - the create will cause adoption if
that is allowed otherwise it will delete the data if that is allowed.

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Update swagger

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Fix tests and migrate overwrite

Signed-off-by: Andrew Thornton <art27@cantab.net>

* as per @lunny

Only offer to adopt or overwrite if the user can do that.

Allow the site administrator to adopt or overwrite in all
circumstances

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Use setting.Repository.DefaultBranch for the default branch

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Always set setting.Repository.DefaultBranch

Signed-off-by: Andrew Thornton <art27@cantab.net>

* update swagger

Signed-off-by: Andrew Thornton <art27@cantab.net>

* update templates

Signed-off-by: Andrew Thornton <art27@cantab.net>

* ensure repo closed

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Rewrite of adoption as per @6543 and @lunny

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Apply suggestions from code review

* update swagger

Signed-off-by: Andrew Thornton <art27@cantab.net>

* missing not

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add modals and flash reporting

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Make the unadopted page searchable

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Add API

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Fix swagger

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fix swagger

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Handle empty and non-master branched repositories

Signed-off-by: Andrew Thornton <art27@cantab.net>

* placate lint

Signed-off-by: Andrew Thornton <art27@cantab.net>

* remove commented out code

Signed-off-by: Andrew Thornton <art27@cantab.net>

Co-authored-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
zeripath 2020-09-25 05:09:23 +01:00 committed by GitHub
parent 6fa19a8458
commit 7a7f56044a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1340 additions and 100 deletions

View file

@ -64,6 +64,10 @@ PREFIX_ARCHIVE_FILES = true
DISABLE_MIRRORS = false DISABLE_MIRRORS = false
; The default branch name of new repositories ; The default branch name of new repositories
DEFAULT_BRANCH=master DEFAULT_BRANCH=master
; Allow adoption of unadopted repositories
ALLOW_ADOPTION_OF_UNADOPTED_REPOSITORIES=false
; Allow deletion of unadopted repositories
ALLOW_DELETION_OF_UNADOPTED_REPOSITORIES=false
[repository.editor] [repository.editor]
; List of file extensions for which lines should be wrapped in the Monaco editor ; List of file extensions for which lines should be wrapped in the Monaco editor

View file

@ -743,6 +743,22 @@ func (err ErrRepoAlreadyExist) Error() string {
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name) return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
} }
// ErrRepoFilesAlreadyExist represents a "RepoFilesAlreadyExist" kind of error.
type ErrRepoFilesAlreadyExist struct {
Uname string
Name string
}
// IsErrRepoFilesAlreadyExist checks if an error is a ErrRepoAlreadyExist.
func IsErrRepoFilesAlreadyExist(err error) bool {
_, ok := err.(ErrRepoFilesAlreadyExist)
return ok
}
func (err ErrRepoFilesAlreadyExist) Error() string {
return fmt.Sprintf("repository files already exist [uname: %s, name: %s]", err.Uname, err.Name)
}
// ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error. // ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error.
type ErrForkAlreadyExist struct { type ErrForkAlreadyExist struct {
Uname string Uname string

View file

@ -278,7 +278,7 @@ func (repo *Repository) IsBeingCreated() bool {
func (repo *Repository) AfterLoad() { func (repo *Repository) AfterLoad() {
// FIXME: use models migration to solve all at once. // FIXME: use models migration to solve all at once.
if len(repo.DefaultBranch) == 0 { if len(repo.DefaultBranch) == 0 {
repo.DefaultBranch = "master" repo.DefaultBranch = setting.Repository.DefaultBranch
} }
repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues
@ -1048,7 +1048,7 @@ func (repo *Repository) CloneLink() (cl *CloneLink) {
} }
// CheckCreateRepository check if could created a repository // CheckCreateRepository check if could created a repository
func CheckCreateRepository(doer, u *User, name string) error { func CheckCreateRepository(doer, u *User, name string, overwriteOrAdopt bool) error {
if !doer.CanCreateRepo() { if !doer.CanCreateRepo() {
return ErrReachLimitOfRepo{u.MaxRepoCreation} return ErrReachLimitOfRepo{u.MaxRepoCreation}
} }
@ -1063,6 +1063,10 @@ func CheckCreateRepository(doer, u *User, name string) error {
} else if has { } else if has {
return ErrRepoAlreadyExist{u.Name, name} return ErrRepoAlreadyExist{u.Name, name}
} }
if !overwriteOrAdopt && com.IsExist(RepoPath(u.Name, name)) {
return ErrRepoFilesAlreadyExist{u.Name, name}
}
return nil return nil
} }
@ -1116,11 +1120,15 @@ var (
// IsUsableRepoName returns true when repository is usable // IsUsableRepoName returns true when repository is usable
func IsUsableRepoName(name string) error { func IsUsableRepoName(name string) error {
if alphaDashDotPattern.MatchString(name) {
// Note: usually this error is normally caught up earlier in the UI
return ErrNameCharsNotAllowed{Name: name}
}
return isUsableName(reservedRepoNames, reservedRepoPatterns, name) return isUsableName(reservedRepoNames, reservedRepoPatterns, name)
} }
// CreateRepository creates a repository for the user/organization. // CreateRepository creates a repository for the user/organization.
func CreateRepository(ctx DBContext, doer, u *User, repo *Repository) (err error) { func CreateRepository(ctx DBContext, doer, u *User, repo *Repository, overwriteOrAdopt bool) (err error) {
if err = IsUsableRepoName(repo.Name); err != nil { if err = IsUsableRepoName(repo.Name); err != nil {
return err return err
} }
@ -1132,6 +1140,15 @@ func CreateRepository(ctx DBContext, doer, u *User, repo *Repository) (err error
return ErrRepoAlreadyExist{u.Name, repo.Name} return ErrRepoAlreadyExist{u.Name, repo.Name}
} }
repoPath := RepoPath(u.Name, repo.Name)
if !overwriteOrAdopt && com.IsExist(repoPath) {
log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath)
return ErrRepoFilesAlreadyExist{
Uname: u.Name,
Name: repo.Name,
}
}
if _, err = ctx.e.Insert(repo); err != nil { if _, err = ctx.e.Insert(repo); err != nil {
return err return err
} }
@ -1856,6 +1873,10 @@ func GetUserRepositories(opts *SearchRepoOptions) ([]*Repository, int64, error)
cond = cond.And(builder.Eq{"is_private": false}) cond = cond.And(builder.Eq{"is_private": false})
} }
if opts.LowerNames != nil && len(opts.LowerNames) > 0 {
cond = cond.And(builder.In("lower_name", opts.LowerNames))
}
sess := x.NewSession() sess := x.NewSession()
defer sess.Close() defer sess.Close()

View file

@ -175,6 +175,8 @@ type SearchRepoOptions struct {
// True -> include just has milestones // True -> include just has milestones
// False -> include just has no milestone // False -> include just has no milestone
HasMilestones util.OptionalBool HasMilestones util.OptionalBool
// LowerNames represents valid lower names to restrict to
LowerNames []string
} }
//SearchOrderBy is used to sort the result //SearchOrderBy is used to sort the result

View file

@ -646,8 +646,8 @@ func (u *User) GetOrganizationCount() (int64, error) {
} }
// GetRepositories returns repositories that user owns, including private repositories. // GetRepositories returns repositories that user owns, including private repositories.
func (u *User) GetRepositories(listOpts ListOptions) (err error) { func (u *User) GetRepositories(listOpts ListOptions, names ...string) (err error) {
u.Repos, _, err = GetUserRepositories(&SearchRepoOptions{Actor: u, Private: true, ListOptions: listOpts}) u.Repos, _, err = GetUserRepositories(&SearchRepoOptions{Actor: u, Private: true, ListOptions: listOpts, LowerNames: names})
return err return err
} }

View file

@ -74,6 +74,11 @@ func (repo *Repository) SetDefaultBranch(name string) error {
return err return err
} }
// GetDefaultBranch gets default branch of repository.
func (repo *Repository) GetDefaultBranch() (string, error) {
return NewCommand("symbolic-ref", "HEAD").RunInDir(repo.Path)
}
// GetBranches returns all branches of the repository. // GetBranches returns all branches of the repository.
func (repo *Repository) GetBranches() ([]string, error) { func (repo *Repository) GetBranches() ([]string, error) {
var branchNames []string var branchNames []string

272
modules/repository/adopt.go Normal file
View file

@ -0,0 +1,272 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package repository
import (
"fmt"
"os"
"path/filepath"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/gobwas/glob"
"github.com/unknwon/com"
)
// AdoptRepository adopts a repository for the user/organization.
func AdoptRepository(doer, u *models.User, opts models.CreateRepoOptions) (*models.Repository, error) {
if !doer.IsAdmin && !u.CanCreateRepo() {
return nil, models.ErrReachLimitOfRepo{
Limit: u.MaxRepoCreation,
}
}
if len(opts.DefaultBranch) == 0 {
opts.DefaultBranch = setting.Repository.DefaultBranch
}
repo := &models.Repository{
OwnerID: u.ID,
Owner: u,
OwnerName: u.Name,
Name: opts.Name,
LowerName: strings.ToLower(opts.Name),
Description: opts.Description,
OriginalURL: opts.OriginalURL,
OriginalServiceType: opts.GitServiceType,
IsPrivate: opts.IsPrivate,
IsFsckEnabled: !opts.IsMirror,
CloseIssuesViaCommitInAnyBranch: setting.Repository.DefaultCloseIssuesViaCommitsInAnyBranch,
Status: opts.Status,
IsEmpty: !opts.AutoInit,
}
if err := models.WithTx(func(ctx models.DBContext) error {
repoPath := models.RepoPath(u.Name, repo.Name)
if !com.IsExist(repoPath) {
return models.ErrRepoNotExist{
OwnerName: u.Name,
Name: repo.Name,
}
}
if err := models.CreateRepository(ctx, doer, u, repo, true); err != nil {
return err
}
if err := adoptRepository(ctx, repoPath, doer, repo, opts); err != nil {
return fmt.Errorf("createDelegateHooks: %v", err)
}
// Initialize Issue Labels if selected
if len(opts.IssueLabels) > 0 {
if err := models.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil {
return fmt.Errorf("InitializeLabels: %v", err)
}
}
if stdout, err := git.NewCommand("update-server-info").
SetDescription(fmt.Sprintf("CreateRepository(git update-server-info): %s", repoPath)).
RunInDir(repoPath); err != nil {
log.Error("CreateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", repo, stdout, err)
return fmt.Errorf("CreateRepository(git update-server-info): %v", err)
}
return nil
}); err != nil {
return nil, err
}
return repo, nil
}
// DeleteUnadoptedRepository deletes unadopted repository files from the filesystem
func DeleteUnadoptedRepository(doer, u *models.User, repoName string) error {
if err := models.IsUsableRepoName(repoName); err != nil {
return err
}
repoPath := models.RepoPath(u.Name, repoName)
if !com.IsExist(repoPath) {
return models.ErrRepoNotExist{
OwnerName: u.Name,
Name: repoName,
}
}
if exist, err := models.IsRepositoryExist(u, repoName); err != nil {
return err
} else if exist {
return models.ErrRepoAlreadyExist{
Uname: u.Name,
Name: repoName,
}
}
return util.RemoveAll(repoPath)
}
// ListUnadoptedRepositories lists all the unadopted repositories that match the provided query
func ListUnadoptedRepositories(query string, opts *models.ListOptions) ([]string, int, error) {
globUser, _ := glob.Compile("*")
globRepo, _ := glob.Compile("*")
qsplit := strings.SplitN(query, "/", 2)
if len(qsplit) > 0 && len(query) > 0 {
var err error
globUser, err = glob.Compile(qsplit[0])
if err != nil {
log.Info("Invalid glob expresion '%s' (skipped): %v", qsplit[0], err)
}
if len(qsplit) > 1 {
globRepo, err = glob.Compile(qsplit[1])
if err != nil {
log.Info("Invalid glob expresion '%s' (skipped): %v", qsplit[1], err)
}
}
}
start := (opts.Page - 1) * opts.PageSize
end := start + opts.PageSize
repoNamesToCheck := make([]string, 0, opts.PageSize)
repoNames := make([]string, 0, opts.PageSize)
var ctxUser *models.User
count := 0
// We're going to iterate by pagesize.
root := filepath.Join(setting.RepoRootPath)
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() || path == root {
return nil
}
if !strings.ContainsRune(path[len(root)+1:], filepath.Separator) {
// Got a new user
// Clean up old repoNamesToCheck
if len(repoNamesToCheck) > 0 {
repos, _, err := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: models.ListOptions{
Page: 1,
PageSize: opts.PageSize,
}, LowerNames: repoNamesToCheck})
if err != nil {
return err
}
for _, name := range repoNamesToCheck {
found := false
repoLoopCatchup:
for i, repo := range repos {
if repo.LowerName == name {
found = true
repos = append(repos[:i], repos[i+1:]...)
break repoLoopCatchup
}
}
if !found {
if count >= start && count < end {
repoNames = append(repoNames, fmt.Sprintf("%s/%s", ctxUser.Name, name))
}
count++
}
}
repoNamesToCheck = repoNamesToCheck[:0]
}
if !globUser.Match(info.Name()) {
return filepath.SkipDir
}
ctxUser, err = models.GetUserByName(info.Name())
if err != nil {
if models.IsErrUserNotExist(err) {
log.Debug("Missing user: %s", info.Name())
return filepath.SkipDir
}
return err
}
return nil
}
name := info.Name()
if !strings.HasSuffix(name, ".git") {
return filepath.SkipDir
}
name = name[:len(name)-4]
if models.IsUsableRepoName(name) != nil || strings.ToLower(name) != name || !globRepo.Match(name) {
return filepath.SkipDir
}
if count < end {
repoNamesToCheck = append(repoNamesToCheck, name)
if len(repoNamesToCheck) >= opts.PageSize {
repos, _, err := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: models.ListOptions{
Page: 1,
PageSize: opts.PageSize,
}, LowerNames: repoNamesToCheck})
if err != nil {
return err
}
for _, name := range repoNamesToCheck {
found := false
repoLoop:
for i, repo := range repos {
if repo.Name == name {
found = true
repos = append(repos[:i], repos[i+1:]...)
break repoLoop
}
}
if !found {
if count >= start && count < end {
repoNames = append(repoNames, fmt.Sprintf("%s/%s", ctxUser.Name, name))
}
count++
}
}
repoNamesToCheck = repoNamesToCheck[:0]
}
return filepath.SkipDir
}
count++
return filepath.SkipDir
}); err != nil {
return nil, 0, err
}
if len(repoNamesToCheck) > 0 {
repos, _, err := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: models.ListOptions{
Page: 1,
PageSize: opts.PageSize,
}, LowerNames: repoNamesToCheck})
if err != nil {
return nil, 0, err
}
for _, name := range repoNamesToCheck {
found := false
repoLoop:
for i, repo := range repos {
if repo.LowerName == name {
found = true
repos = append(repos[:i], repos[i+1:]...)
break repoLoop
}
}
if !found {
if count >= start && count < end {
repoNames = append(repoNames, fmt.Sprintf("%s/%s", ctxUser.Name, name))
}
count++
}
}
}
return repoNames, count, nil
}

View file

@ -13,10 +13,12 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/unknwon/com"
) )
// CreateRepository creates a repository for the user/organization. // CreateRepository creates a repository for the user/organization.
func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (_ *models.Repository, err error) { func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (*models.Repository, error) {
if !doer.IsAdmin && !u.CanCreateRepo() { if !doer.IsAdmin && !u.CanCreateRepo() {
return nil, models.ErrReachLimitOfRepo{ return nil, models.ErrReachLimitOfRepo{
Limit: u.MaxRepoCreation, Limit: u.MaxRepoCreation,
@ -44,15 +46,33 @@ func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (_ *m
TrustModel: opts.TrustModel, TrustModel: opts.TrustModel,
} }
err = models.WithTx(func(ctx models.DBContext) error { if err := models.WithTx(func(ctx models.DBContext) error {
if err = models.CreateRepository(ctx, doer, u, repo); err != nil { if err := models.CreateRepository(ctx, doer, u, repo, false); err != nil {
return err return err
} }
// No need for init mirror. // No need for init mirror.
if !opts.IsMirror { if opts.IsMirror {
return nil
}
repoPath := models.RepoPath(u.Name, repo.Name) repoPath := models.RepoPath(u.Name, repo.Name)
if err = initRepository(ctx, repoPath, doer, repo, opts); err != nil { if com.IsExist(repoPath) {
// repo already exists - We have two or three options.
// 1. We fail stating that the directory exists
// 2. We create the db repository to go with this data and adopt the git repo
// 3. We delete it and start afresh
//
// Previously Gitea would just delete and start afresh - this was naughty.
// So we will now fail and delegate to other functionality to adopt or delete
log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath)
return models.ErrRepoFilesAlreadyExist{
Uname: u.Name,
Name: repo.Name,
}
}
if err := initRepository(ctx, repoPath, doer, repo, opts); err != nil {
if err2 := util.RemoveAll(repoPath); err2 != nil { if err2 := util.RemoveAll(repoPath); err2 != nil {
log.Error("initRepository: %v", err) log.Error("initRepository: %v", err)
return fmt.Errorf( return fmt.Errorf(
@ -63,7 +83,10 @@ func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (_ *m
// Initialize Issue Labels if selected // Initialize Issue Labels if selected
if len(opts.IssueLabels) > 0 { if len(opts.IssueLabels) > 0 {
if err = models.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil { if err := models.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil {
if errDelete := models.DeleteRepository(doer, u.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
return fmt.Errorf("InitializeLabels: %v", err) return fmt.Errorf("InitializeLabels: %v", err)
} }
} }
@ -72,11 +95,15 @@ func CreateRepository(doer, u *models.User, opts models.CreateRepoOptions) (_ *m
SetDescription(fmt.Sprintf("CreateRepository(git update-server-info): %s", repoPath)). SetDescription(fmt.Sprintf("CreateRepository(git update-server-info): %s", repoPath)).
RunInDir(repoPath); err != nil { RunInDir(repoPath); err != nil {
log.Error("CreateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", repo, stdout, err) log.Error("CreateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", repo, stdout, err)
if errDelete := models.DeleteRepository(doer, u.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
return fmt.Errorf("CreateRepository(git update-server-info): %v", err) return fmt.Errorf("CreateRepository(git update-server-info): %v", err)
} }
}
return nil return nil
}) }); err != nil {
return nil, err
}
return repo, err return repo, nil
} }

View file

@ -46,11 +46,21 @@ func ForkRepository(doer, owner *models.User, oldRepo *models.Repository, name,
oldRepoPath := oldRepo.RepoPath() oldRepoPath := oldRepo.RepoPath()
err = models.WithTx(func(ctx models.DBContext) error { err = models.WithTx(func(ctx models.DBContext) error {
if err = models.CreateRepository(ctx, doer, owner, repo); err != nil { if err = models.CreateRepository(ctx, doer, owner, repo, false); err != nil {
return err return err
} }
rollbackRemoveFn := func() {
if repo.ID == 0 {
return
}
if errDelete := models.DeleteRepository(doer, owner.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
}
if err = models.IncrementRepoForkNum(ctx, oldRepo.ID); err != nil { if err = models.IncrementRepoForkNum(ctx, oldRepo.ID); err != nil {
rollbackRemoveFn()
return err return err
} }
@ -60,6 +70,7 @@ func ForkRepository(doer, owner *models.User, oldRepo *models.Repository, name,
SetDescription(fmt.Sprintf("ForkRepository(git clone): %s to %s", oldRepo.FullName(), repo.FullName())). SetDescription(fmt.Sprintf("ForkRepository(git clone): %s to %s", oldRepo.FullName(), repo.FullName())).
RunInDirTimeout(10*time.Minute, ""); err != nil { RunInDirTimeout(10*time.Minute, ""); err != nil {
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, oldRepo, stdout, err) log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, oldRepo, stdout, err)
rollbackRemoveFn()
return fmt.Errorf("git clone: %v", err) return fmt.Errorf("git clone: %v", err)
} }
@ -67,10 +78,12 @@ func ForkRepository(doer, owner *models.User, oldRepo *models.Repository, name,
SetDescription(fmt.Sprintf("ForkRepository(git update-server-info): %s", repo.FullName())). SetDescription(fmt.Sprintf("ForkRepository(git update-server-info): %s", repo.FullName())).
RunInDir(repoPath); err != nil { RunInDir(repoPath); err != nil {
log.Error("Fork Repository (git update-server-info) failed for %v:\nStdout: %s\nError: %v", repo, stdout, err) log.Error("Fork Repository (git update-server-info) failed for %v:\nStdout: %s\nError: %v", repo, stdout, err)
rollbackRemoveFn()
return fmt.Errorf("git update-server-info: %v", err) return fmt.Errorf("git update-server-info: %v", err)
} }
if err = createDelegateHooks(repoPath); err != nil { if err = createDelegateHooks(repoPath); err != nil {
rollbackRemoveFn()
return fmt.Errorf("createDelegateHooks: %v", err) return fmt.Errorf("createDelegateHooks: %v", err)
} }
return nil return nil
@ -86,5 +99,12 @@ func ForkRepository(doer, owner *models.User, oldRepo *models.Repository, name,
if err := models.CopyLanguageStat(oldRepo, repo); err != nil { if err := models.CopyLanguageStat(oldRepo, repo); err != nil {
log.Error("Copy language stat from oldRepo failed") log.Error("Copy language stat from oldRepo failed")
} }
return repo, models.CopyLFS(ctx, repo, oldRepo)
if err := models.CopyLFS(ctx, repo, oldRepo); err != nil {
if errDelete := models.DeleteRepository(doer, owner.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
return nil, err
}
return repo, nil
} }

View file

@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/huandu/xstrings" "github.com/huandu/xstrings"
"github.com/unknwon/com"
) )
type transformer struct { type transformer struct {
@ -246,12 +247,19 @@ func GenerateRepository(ctx models.DBContext, doer, owner *models.User, template
TrustModel: templateRepo.TrustModel, TrustModel: templateRepo.TrustModel,
} }
if err = models.CreateRepository(ctx, doer, owner, generateRepo); err != nil { if err = models.CreateRepository(ctx, doer, owner, generateRepo, false); err != nil {
return nil, err return nil, err
} }
repoPath := models.RepoPath(owner.Name, generateRepo.Name) repoPath := generateRepo.RepoPath()
if err = checkInitRepository(repoPath); err != nil { if com.IsExist(repoPath) {
return nil, models.ErrRepoFilesAlreadyExist{
Uname: generateRepo.OwnerName,
Name: generateRepo.Name,
}
}
if err = checkInitRepository(owner.Name, generateRepo.Name); err != nil {
return generateRepo, err return generateRepo, err
} }

View file

@ -172,10 +172,14 @@ func initRepoCommit(tmpPath string, repo *models.Repository, u *models.User, def
return nil return nil
} }
func checkInitRepository(repoPath string) (err error) { func checkInitRepository(owner, name string) (err error) {
// Somehow the directory could exist. // Somehow the directory could exist.
repoPath := models.RepoPath(owner, name)
if com.IsExist(repoPath) { if com.IsExist(repoPath) {
return fmt.Errorf("checkInitRepository: path already exists: %s", repoPath) return models.ErrRepoFilesAlreadyExist{
Uname: owner,
Name: name,
}
} }
// Init git bare new repository. // Init git bare new repository.
@ -187,9 +191,85 @@ func checkInitRepository(repoPath string) (err error) {
return nil return nil
} }
func adoptRepository(ctx models.DBContext, repoPath string, u *models.User, repo *models.Repository, opts models.CreateRepoOptions) (err error) {
if !com.IsExist(repoPath) {
return fmt.Errorf("adoptRepository: path does not already exist: %s", repoPath)
}
if err := createDelegateHooks(repoPath); err != nil {
return fmt.Errorf("createDelegateHooks: %v", err)
}
// Re-fetch the repository from database before updating it (else it would
// override changes that were done earlier with sql)
if repo, err = models.GetRepositoryByIDCtx(ctx, repo.ID); err != nil {
return fmt.Errorf("getRepositoryByID: %v", err)
}
repo.IsEmpty = false
gitRepo, err := git.OpenRepository(repo.RepoPath())
if err != nil {
return fmt.Errorf("openRepository: %v", err)
}
defer gitRepo.Close()
if len(opts.DefaultBranch) > 0 {
repo.DefaultBranch = opts.DefaultBranch
if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil {
return fmt.Errorf("setDefaultBranch: %v", err)
}
} else {
repo.DefaultBranch, err = gitRepo.GetDefaultBranch()
if err != nil {
repo.DefaultBranch = setting.Repository.DefaultBranch
if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil {
return fmt.Errorf("setDefaultBranch: %v", err)
}
}
repo.DefaultBranch = strings.TrimPrefix(repo.DefaultBranch, git.BranchPrefix)
}
branches, _ := gitRepo.GetBranches()
found := false
hasDefault := false
hasMaster := false
for _, branch := range branches {
if branch == repo.DefaultBranch {
found = true
break
} else if branch == setting.Repository.DefaultBranch {
hasDefault = true
} else if branch == "master" {
hasMaster = true
}
}
if !found {
if hasDefault {
repo.DefaultBranch = setting.Repository.DefaultBranch
} else if hasMaster {
repo.DefaultBranch = "master"
} else if len(branches) > 0 {
repo.DefaultBranch = branches[0]
} else {
repo.IsEmpty = true
repo.DefaultBranch = setting.Repository.DefaultBranch
}
if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil {
return fmt.Errorf("setDefaultBranch: %v", err)
}
}
if err = models.UpdateRepositoryCtx(ctx, repo, false); err != nil {
return fmt.Errorf("updateRepository: %v", err)
}
return nil
}
// InitRepository initializes README and .gitignore if needed. // InitRepository initializes README and .gitignore if needed.
func initRepository(ctx models.DBContext, repoPath string, u *models.User, repo *models.Repository, opts models.CreateRepoOptions) (err error) { func initRepository(ctx models.DBContext, repoPath string, u *models.User, repo *models.Repository, opts models.CreateRepoOptions) (err error) {
if err = checkInitRepository(repoPath); err != nil { if err = checkInitRepository(repo.OwnerName, repo.Name); err != nil {
return err return err
} }
@ -225,7 +305,8 @@ func initRepository(ctx models.DBContext, repoPath string, u *models.User, repo
repo.IsEmpty = true repo.IsEmpty = true
} }
repo.DefaultBranch = "master" repo.DefaultBranch = setting.Repository.DefaultBranch
if len(opts.DefaultBranch) > 0 { if len(opts.DefaultBranch) > 0 {
repo.DefaultBranch = opts.DefaultBranch repo.DefaultBranch = opts.DefaultBranch
gitRepo, err := git.OpenRepository(repo.RepoPath()) gitRepo, err := git.OpenRepository(repo.RepoPath())

View file

@ -44,6 +44,8 @@ var (
PrefixArchiveFiles bool PrefixArchiveFiles bool
DisableMirrors bool DisableMirrors bool
DefaultBranch string DefaultBranch string
AllowAdoptionOfUnadoptedRepositories bool
AllowDeleteOfUnadoptedRepositories bool
// Repository editor settings // Repository editor settings
Editor struct { Editor struct {
@ -146,6 +148,7 @@ var (
DefaultRepoUnits: []string{}, DefaultRepoUnits: []string{},
PrefixArchiveFiles: true, PrefixArchiveFiles: true,
DisableMirrors: false, DisableMirrors: false,
DefaultBranch: "master",
// Repository editor settings // Repository editor settings
Editor: struct { Editor: struct {
@ -245,7 +248,7 @@ func newRepository() {
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1)
Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString("master") Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch)
RepoRootPath = sec.Key("ROOT").MustString(path.Join(homeDir, "gitea-repositories")) RepoRootPath = sec.Key("ROOT").MustString(path.Join(homeDir, "gitea-repositories"))
forcePathSeparator(RepoRootPath) forcePathSeparator(RepoRootPath)
if !filepath.IsAbs(RepoRootPath) { if !filepath.IsAbs(RepoRootPath) {

View file

@ -356,6 +356,10 @@ lang_select_error = Select a language from the list.
username_been_taken = The username is already taken. username_been_taken = The username is already taken.
repo_name_been_taken = The repository name is already used. repo_name_been_taken = The repository name is already used.
repository_files_already_exist = Files already exist for this repository. Contact the system administrator.
repository_files_already_exist.adopt = Files already exist for this repository and can only be Adopted.
repository_files_already_exist.delete = Files already exist for this repository. You must delete them.
repository_files_already_exist.adopt_or_delete = Files already exist for this repository. Either adopt them or delete them.
visit_rate_limit = Remote visit addressed rate limitation. visit_rate_limit = Remote visit addressed rate limitation.
2fa_auth_required = Remote visit required two factors authentication. 2fa_auth_required = Remote visit required two factors authentication.
org_name_been_taken = The organization name is already taken. org_name_been_taken = The organization name is already taken.
@ -682,6 +686,15 @@ pick_reaction = Pick your reaction
reactions_more = and %d more reactions_more = and %d more
unit_disabled = The site administrator has disabled this repository section. unit_disabled = The site administrator has disabled this repository section.
language_other = Other language_other = Other
adopt_search = Enter username to search for unadopted repositories... (leave blank to find all)
adopt_preexisting_label = Adopt Files
adopt_preexisting = Adopt pre-existing files
adopt_preexisting_content = Create repository from %s
adopt_preexisting_success = Adopted files and created repository from %s
delete_preexisting_label = Delete
delete_preexisting = Delete pre-existing files
delete_preexisting_content = Delete files in %s
delete_preexisting_success = Deleted unadopted files in %s
desc.private = Private desc.private = Private
desc.public = Public desc.public = Public
@ -2069,6 +2082,8 @@ orgs.members = Members
orgs.new_orga = New Organization orgs.new_orga = New Organization
repos.repo_manage_panel = Repository Management repos.repo_manage_panel = Repository Management
repos.unadopted = Unadopted Repositories
repos.unadopted.no_more = No more unadopted repositories found
repos.owner = Owner repos.owner = Owner
repos.name = Name repos.name = Name
repos.private = Private repos.private = Private

View file

@ -5,17 +5,22 @@
package admin package admin
import ( import (
"strings"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"github.com/unknwon/com"
) )
const ( const (
tplRepos base.TplName = "admin/repo/list" tplRepos base.TplName = "admin/repo/list"
tplUnadoptedRepos base.TplName = "admin/repo/unadopted"
) )
// Repos show all the repositories // Repos show all the repositories
@ -50,3 +55,91 @@ func DeleteRepo(ctx *context.Context) {
"redirect": setting.AppSubURL + "/admin/repos?page=" + ctx.Query("page") + "&sort=" + ctx.Query("sort"), "redirect": setting.AppSubURL + "/admin/repos?page=" + ctx.Query("page") + "&sort=" + ctx.Query("sort"),
}) })
} }
// UnadoptedRepos lists the unadopted repositories
func UnadoptedRepos(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.repositories")
ctx.Data["PageIsAdmin"] = true
ctx.Data["PageIsAdminRepositories"] = true
opts := models.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
Page: ctx.QueryInt("page"),
}
if opts.Page <= 0 {
opts.Page = 1
}
doSearch := ctx.QueryBool("search")
ctx.Data["search"] = doSearch
q := ctx.Query("q")
if !doSearch {
pager := context.NewPagination(0, opts.PageSize, opts.Page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.HTML(200, tplUnadoptedRepos)
return
}
ctx.Data["Keyword"] = q
repoNames, count, err := repository.ListUnadoptedRepositories(q, &opts)
if err != nil {
ctx.ServerError("ListUnadoptedRepositories", err)
}
ctx.Data["Dirs"] = repoNames
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.HTML(200, tplUnadoptedRepos)
}
// AdoptOrDeleteRepository adopts or deletes a repository
func AdoptOrDeleteRepository(ctx *context.Context) {
dir := ctx.Query("id")
action := ctx.Query("action")
dirSplit := strings.SplitN(dir, "/", 2)
if len(dirSplit) != 2 {
ctx.Redirect(setting.AppSubURL + "/admin/repos")
return
}
ctxUser, err := models.GetUserByName(dirSplit[0])
if err != nil {
if models.IsErrUserNotExist(err) {
log.Debug("User does not exist: %s", dirSplit[0])
ctx.Redirect(setting.AppSubURL + "/admin/repos")
return
}
ctx.ServerError("GetUserByName", err)
return
}
repoName := dirSplit[1]
// check not a repo
if has, err := models.IsRepositoryExist(ctxUser, repoName); err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
} else if has || !com.IsDir(models.RepoPath(ctxUser.Name, repoName)) {
// Fallthrough to failure mode
} else if action == "adopt" {
if _, err := repository.AdoptRepository(ctx.User, ctxUser, models.CreateRepoOptions{
Name: dirSplit[1],
IsPrivate: true,
}); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" {
if err := repository.DeleteUnadoptedRepository(ctx.User, ctxUser, dirSplit[1]); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir))
}
ctx.Redirect(setting.AppSubURL + "/admin/repos/unadopted")
}

View file

@ -0,0 +1,164 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package admin
import (
"fmt"
"net/http"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/routers/api/v1/utils"
"github.com/unknwon/com"
)
// ListUnadoptedRepositories lists the unadopted repositories that match the provided names
func ListUnadoptedRepositories(ctx *context.APIContext) {
// swagger:operation GET /admin/unadopted admin adminUnadoptedList
// ---
// summary: List unadopted repositories
// produces:
// - application/json
// parameters:
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// - name: pattern
// in: query
// description: pattern of repositories to search for
// type: string
// responses:
// "200":
// "$ref": "#/responses/StringSlice"
// "403":
// "$ref": "#/responses/forbidden"
listOptions := utils.GetListOptions(ctx)
repoNames, count, err := repository.ListUnadoptedRepositories(ctx.Query("query"), &listOptions)
if err != nil {
ctx.InternalServerError(err)
}
ctx.Header().Set("X-Total-Count", fmt.Sprintf("%d", count))
ctx.Header().Set("Access-Control-Expose-Headers", "X-Total-Count")
ctx.JSON(http.StatusOK, repoNames)
}
// AdoptRepository will adopt an unadopted repository
func AdoptRepository(ctx *context.APIContext) {
// swagger:operation POST /admin/unadopted/{owner}/{repo} admin adminAdoptRepository
// ---
// summary: Adopt unadopted files as a repository
// 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"
// "404":
// "$ref": "#/responses/notFound"
// "403":
// "$ref": "#/responses/forbidden"
ownerName := ctx.Params(":username")
repoName := ctx.Params(":reponame")
ctxUser, err := models.GetUserByName(ownerName)
if err != nil {
if models.IsErrUserNotExist(err) {
ctx.NotFound()
return
}
ctx.InternalServerError(err)
return
}
// check not a repo
if has, err := models.IsRepositoryExist(ctxUser, repoName); err != nil {
ctx.InternalServerError(err)
return
} else if has || !com.IsDir(models.RepoPath(ctxUser.Name, repoName)) {
ctx.NotFound()
return
}
if _, err := repository.AdoptRepository(ctx.User, ctxUser, models.CreateRepoOptions{
Name: repoName,
IsPrivate: true,
}); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}
// DeleteUnadoptedRepository will delete an unadopted repository
func DeleteUnadoptedRepository(ctx *context.APIContext) {
// swagger:operation DELETE /admin/unadopted/{owner}/{repo} admin adminDeleteUnadoptedRepository
// ---
// summary: Delete unadopted files
// 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"
// "403":
// "$ref": "#/responses/forbidden"
ownerName := ctx.Params(":username")
repoName := ctx.Params(":reponame")
ctxUser, err := models.GetUserByName(ownerName)
if err != nil {
if models.IsErrUserNotExist(err) {
ctx.NotFound()
return
}
ctx.InternalServerError(err)
return
}
// check not a repo
if has, err := models.IsRepositoryExist(ctxUser, repoName); err != nil {
ctx.InternalServerError(err)
return
} else if has || !com.IsDir(models.RepoPath(ctxUser.Name, repoName)) {
ctx.NotFound()
return
}
if err := repository.DeleteUnadoptedRepository(ctx.User, ctxUser, repoName); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -957,6 +957,11 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo)
}) })
}) })
m.Group("/unadopted", func() {
m.Get("", admin.ListUnadoptedRepositories)
m.Post("/:username/:reponame", admin.AdoptRepository)
m.Delete("/:username/:reponame", admin.DeleteUnadoptedRepository)
})
}, reqToken(), reqSiteAdmin()) }, reqToken(), reqSiteAdmin())
m.Group("/topics", func() { m.Group("/topics", func() {

View file

@ -198,6 +198,8 @@ func handleMigrateError(ctx *context.APIContext, repoOwner *models.User, remoteA
switch { switch {
case models.IsErrRepoAlreadyExist(err): case models.IsErrRepoAlreadyExist(err):
ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.") ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.")
case models.IsErrRepoFilesAlreadyExist(err):
ctx.Error(http.StatusConflict, "", "Files already exist for this repository. Adopt them or delete them.")
case migrations.IsRateLimitError(err): case migrations.IsRateLimitError(err):
ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit addressed rate limitation.") ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit addressed rate limitation.")
case migrations.IsTwoFactorAuthError(err): case migrations.IsTwoFactorAuthError(err):

View file

@ -67,6 +67,18 @@ func handleMigrateError(ctx *context.Context, owner *models.User, err error, nam
case models.IsErrRepoAlreadyExist(err): case models.IsErrRepoAlreadyExist(err):
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form)
case models.IsErrRepoFilesAlreadyExist(err):
ctx.Data["Err_RepoName"] = true
switch {
case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories):
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form)
case setting.Repository.AllowAdoptionOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form)
case setting.Repository.AllowDeleteOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form)
default:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form)
}
case models.IsErrNameReserved(err): case models.IsErrNameReserved(err):
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form)
@ -159,7 +171,7 @@ func MigratePost(ctx *context.Context, form auth.MigrateRepoForm) {
opts.Releases = false opts.Releases = false
} }
err = models.CheckCreateRepository(ctx.User, ctxUser, opts.RepoName) err = models.CheckCreateRepository(ctx.User, ctxUser, opts.RepoName, false)
if err != nil { if err != nil {
handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, &form) handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, &form)
return return

View file

@ -160,6 +160,18 @@ func handleCreateError(ctx *context.Context, owner *models.User, err error, name
case models.IsErrRepoAlreadyExist(err): case models.IsErrRepoAlreadyExist(err):
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form)
case models.IsErrRepoFilesAlreadyExist(err):
ctx.Data["Err_RepoName"] = true
switch {
case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories):
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form)
case setting.Repository.AllowAdoptionOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form)
case setting.Repository.AllowDeleteOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form)
default:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form)
}
case models.IsErrNameReserved(err): case models.IsErrNameReserved(err):
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form)

View file

@ -88,6 +88,18 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form)
case models.IsErrNameReserved(err): case models.IsErrNameReserved(err):
ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tplSettingsOptions, &form) ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(models.ErrNameReserved).Name), tplSettingsOptions, &form)
case models.IsErrRepoFilesAlreadyExist(err):
ctx.Data["Err_RepoName"] = true
switch {
case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories):
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form)
case setting.Repository.AllowAdoptionOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form)
case setting.Repository.AllowDeleteOfUnadoptedRepositories:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form)
default:
ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form)
}
case models.IsErrNamePatternNotAllowed(err): case models.IsErrNamePatternNotAllowed(err):
ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form)
default: default:

View file

@ -402,6 +402,7 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Post("/keys/delete", userSetting.DeleteKey) m.Post("/keys/delete", userSetting.DeleteKey)
m.Get("/organization", userSetting.Organization) m.Get("/organization", userSetting.Organization)
m.Get("/repos", userSetting.Repos) m.Get("/repos", userSetting.Repos)
m.Post("/repos/unadopted", userSetting.AdoptOrDeleteRepository)
}, reqSignIn, func(ctx *context.Context) { }, reqSignIn, func(ctx *context.Context) {
ctx.Data["PageIsUserSettings"] = true ctx.Data["PageIsUserSettings"] = true
ctx.Data["AllThemes"] = setting.UI.Themes ctx.Data["AllThemes"] = setting.UI.Themes
@ -461,6 +462,7 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Group("/repos", func() { m.Group("/repos", func() {
m.Get("", admin.Repos) m.Get("", admin.Repos)
m.Combo("/unadopted").Get(admin.UnadoptedRepos).Post(admin.AdoptOrDeleteRepository)
m.Post("/delete", admin.DeleteRepo) m.Post("/delete", admin.DeleteRepo)
}) })

View file

@ -0,0 +1,56 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package setting
import (
"path/filepath"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"github.com/unknwon/com"
)
// AdoptOrDeleteRepository adopts or deletes a repository
func AdoptOrDeleteRepository(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsRepos"] = true
allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
ctx.Data["allowAdopt"] = allowAdopt
allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
ctx.Data["allowDelete"] = allowDelete
dir := ctx.Query("id")
action := ctx.Query("action")
ctxUser := ctx.User
root := filepath.Join(models.UserPath(ctxUser.LowerName))
// check not a repo
if has, err := models.IsRepositoryExist(ctxUser, dir); err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
} else if has || !com.IsDir(filepath.Join(root, dir+".git")) {
// Fallthrough to failure mode
} else if action == "adopt" && allowAdopt {
if _, err := repository.AdoptRepository(ctxUser, ctxUser, models.CreateRepoOptions{
Name: dir,
IsPrivate: true,
}); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" && allowDelete {
if err := repository.DeleteUnadoptedRepository(ctxUser, ctxUser, dir); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir))
}
ctx.Redirect(setting.AppSubURL + "/user/settings/repos")
}

View file

@ -9,6 +9,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os"
"path/filepath"
"strings" "strings"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
@ -197,32 +199,96 @@ func Organization(ctx *context.Context) {
func Repos(ctx *context.Context) { func Repos(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsRepos"] = true ctx.Data["PageIsSettingsRepos"] = true
ctxUser := ctx.User ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
var err error opts := models.ListOptions{
if err = ctxUser.GetRepositories(models.ListOptions{Page: 1, PageSize: setting.UI.User.RepoPagingNum}); err != nil { PageSize: setting.UI.Admin.UserPagingNum,
Page: ctx.QueryInt("page"),
}
if opts.Page <= 0 {
opts.Page = 1
}
start := (opts.Page - 1) * opts.PageSize
end := start + opts.PageSize
adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories)
ctxUser := ctx.User
count := 0
if adoptOrDelete {
repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum)
repos := map[string]*models.Repository{}
// We're going to iterate by pagesize.
root := filepath.Join(models.UserPath(ctxUser.Name))
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() || path == root {
return nil
}
name := info.Name()
if !strings.HasSuffix(name, ".git") {
return filepath.SkipDir
}
name = name[:len(name)-4]
if models.IsUsableRepoName(name) != nil || strings.ToLower(name) != name {
return filepath.SkipDir
}
if count >= start && count < end {
repoNames = append(repoNames, name)
}
count++
return filepath.SkipDir
}); err != nil {
ctx.ServerError("filepath.Walk", err)
return
}
if err := ctxUser.GetRepositories(models.ListOptions{Page: 1, PageSize: setting.UI.Admin.UserPagingNum}, repoNames...); err != nil {
ctx.ServerError("GetRepositories", err) ctx.ServerError("GetRepositories", err)
return return
} }
for _, repo := range ctxUser.Repos {
if repo.IsFork {
if err := repo.GetBaseRepo(); err != nil {
ctx.ServerError("GetBaseRepo", err)
return
}
}
repos[repo.LowerName] = repo
}
ctx.Data["Dirs"] = repoNames
ctx.Data["ReposMap"] = repos
} else {
var err error
var count64 int64
ctxUser.Repos, count64, err = models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts})
if err != nil {
ctx.ServerError("GetRepositories", err)
return
}
count = int(count64)
repos := ctxUser.Repos repos := ctxUser.Repos
for i := range repos { for i := range repos {
if repos[i].IsFork { if repos[i].IsFork {
err := repos[i].GetBaseRepo() if err := repos[i].GetBaseRepo(); err != nil {
if err != nil {
ctx.ServerError("GetBaseRepo", err) ctx.ServerError("GetBaseRepo", err)
return return
} }
err = repos[i].BaseRepo.GetOwner()
if err != nil {
ctx.ServerError("GetOwner", err)
return
}
} }
} }
ctx.Data["Owner"] = ctxUser
ctx.Data["Repos"] = repos ctx.Data["Repos"] = repos
}
ctx.Data["Owner"] = ctxUser
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.HTML(200, tplSettingsRepositories) ctx.HTML(200, tplSettingsRepositories)
} }

View file

@ -64,7 +64,7 @@ func GenerateRepository(doer, owner *models.User, templateRepo *models.Repositor
return nil return nil
}); err != nil { }); err != nil {
if generateRepo != nil { if generateRepo != nil && generateRepo.ID > 0 {
if errDelete := models.DeleteRepository(doer, owner.ID, generateRepo.ID); errDelete != nil { if errDelete := models.DeleteRepository(doer, owner.ID, generateRepo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete) log.Error("Rollback deleteRepository: %v", errDelete)
} }

View file

@ -18,11 +18,7 @@ import (
func CreateRepository(doer, owner *models.User, opts models.CreateRepoOptions) (*models.Repository, error) { func CreateRepository(doer, owner *models.User, opts models.CreateRepoOptions) (*models.Repository, error) {
repo, err := repo_module.CreateRepository(doer, owner, opts) repo, err := repo_module.CreateRepository(doer, owner, opts)
if err != nil { if err != nil {
if repo != nil { // No need to rollback here we should do this in CreateRepository...
if errDelete := models.DeleteRepository(doer, owner.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
}
return nil, err return nil, err
} }
@ -31,15 +27,28 @@ func CreateRepository(doer, owner *models.User, opts models.CreateRepoOptions) (
return repo, nil return repo, nil
} }
// AdoptRepository adopts pre-existing repository files for the user/organization.
func AdoptRepository(doer, owner *models.User, opts models.CreateRepoOptions) (*models.Repository, error) {
repo, err := repo_module.AdoptRepository(doer, owner, opts)
if err != nil {
// No need to rollback here we should do this in AdoptRepository...
return nil, err
}
notification.NotifyCreateRepository(doer, owner, repo)
return repo, nil
}
// DeleteUnadoptedRepository adopts pre-existing repository files for the user/organization.
func DeleteUnadoptedRepository(doer, owner *models.User, name string) error {
return repo_module.DeleteUnadoptedRepository(doer, owner, name)
}
// ForkRepository forks a repository // ForkRepository forks a repository
func ForkRepository(doer, u *models.User, oldRepo *models.Repository, name, desc string) (*models.Repository, error) { func ForkRepository(doer, u *models.User, oldRepo *models.Repository, name, desc string) (*models.Repository, error) {
repo, err := repo_module.ForkRepository(doer, u, oldRepo, name, desc) repo, err := repo_module.ForkRepository(doer, u, oldRepo, name, desc)
if err != nil { if err != nil {
if repo != nil {
if errDelete := models.DeleteRepository(doer, u.ID, repo.ID); errDelete != nil {
log.Error("Rollback deleteRepository: %v", errDelete)
}
}
return nil, err return nil, err
} }

View file

@ -5,6 +5,9 @@
{{template "base/alert" .}} {{template "base/alert" .}}
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{.i18n.Tr "admin.repos.repo_manage_panel"}} ({{.i18n.Tr "admin.total" .Total}}) {{.i18n.Tr "admin.repos.repo_manage_panel"}} ({{.i18n.Tr "admin.total" .Total}})
<div class="ui right">
<a class="ui blue tiny button" href="{{AppSubUrl}}/admin/repos/unadopted">{{.i18n.Tr "admin.repos.unadopted"}}</a>
</div>
</h4> </h4>
<div class="ui attached segment"> <div class="ui attached segment">
{{template "admin/repo/search" .}} {{template "admin/repo/search" .}}

View file

@ -0,0 +1,98 @@
{{template "base/head" .}}
<div class="admin user">
{{template "admin/navbar" .}}
<div class="ui container">
{{template "base/alert" .}}
<h4 class="ui top attached header">
{{.i18n.Tr "admin.repos.unadopted"}}
<div class="ui right">
<a class="ui blue tiny button" href="{{AppSubUrl}}/admin/repos">{{.i18n.Tr "admin.repos.repo_manage_panel"}}</a>
</div>
</h4>
<div class="ui attached segment">
<form class="ui form ignore-dirty">
<div class="ui fluid action input">
<input name="search" value="true" type="hidden">
<input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "repo.adopt_search"}}" autofocus>
<button class="ui blue button">{{.i18n.Tr "explore.search"}}</button>
</div>
</form>
</div>
{{if .search}}
<div class="ui attached segment settings">
{{if .Dirs}}
<div class="ui middle aligned divided list">
{{range $dirI, $dir := .Dirs}}
<div class="item">
<div class="content">
<span class="icon">{{svg "octicon-file-directory"}}</span>
<span class="name">{{$dir}}</span>
<div class="right floated content">
<button class="ui button submit tiny green adopt show-modal" data-modal="#adopt-unadopted-modal-{{$dirI}}"><span class="icon">{{svg "octicon-plus"}}</span><span class="label">{{$.i18n.Tr "repo.adopt_preexisting_label"}}</span></button>
<div class="ui basic modal" id="adopt-unadopted-modal-{{$dirI}}">
<i class="close icon"></i>
<div class="header">
<span class="label">{{$.i18n.Tr "repo.adopt_preexisting"}}</span>
</div>
<div class="content">
<p>{{$.i18n.Tr "repo.adopt_preexisting_content" $dir}}</p>
</div>
<form class="ui form" method="POST" action="{{AppSubUrl}}/admin/repos/unadopted">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{$dir}}">
<input type="hidden" name="action" value="adopt">
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{$.i18n.Tr "modal.no"}}
</div>
<button class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{$.i18n.Tr "modal.yes"}}
</button>
</div>
</form>
</div>
<button class="ui button submit tiny red delete show-modal" data-modal="#delete-unadopted-modal-{{$dirI}}"><span class="icon">{{svg "octicon-x"}}</span><span class="label">{{$.i18n.Tr "repo.delete_preexisting_label"}}</span></button>
<div class="ui basic modal" id="delete-unadopted-modal-{{$dirI}}">
<i class="close icon"></i>
<div class="header">
<span class="label">{{$.i18n.Tr "repo.delete_preexisting"}}</span>
</div>
<div class="content">
<p>{{$.i18n.Tr "repo.delete_preexisting_content" $dir}}</p>
</div>
<form class="ui form" method="POST" action="{{AppSubUrl}}/admin/repos/unadopted">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{$dir}}">
<input type="hidden" name="action" value="delete">
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{$.i18n.Tr "modal.no"}}
</div>
<button class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{$.i18n.Tr "modal.yes"}}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{template "base/paginate" .}}
{{else}}
<div class="item">
{{.i18n.Tr "admin.repos.unadopted.no_more"}}
</div>
{{template "base/paginate" .}}
{{end}}
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}

View file

@ -120,6 +120,119 @@
} }
} }
}, },
"/admin/unadopted": {
"get": {
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "List unadopted repositories",
"operationId": "adminUnadoptedList",
"parameters": [
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "pattern of repositories to search for",
"name": "pattern",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/StringSlice"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/admin/unadopted/{owner}/{repo}": {
"post": {
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Adopt unadopted files as a repository",
"operationId": "adminAdoptRepository",
"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"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Delete unadopted files",
"operationId": "adminDeleteUnadoptedRepository",
"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"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/admin/users": { "/admin/users": {
"get": { "get": {
"produces": [ "produces": [

View file

@ -7,6 +7,102 @@
{{.i18n.Tr "settings.repos"}} {{.i18n.Tr "settings.repos"}}
</h4> </h4>
<div class="ui attached segment"> <div class="ui attached segment">
{{if or .allowAdopt .allowDelete}}
{{if .Dirs}}
<div class="ui middle aligned divided list">
{{range $dirI, $dir := .Dirs}}
{{ $repo := index $.ReposMap $dir}}
<div class="item">
<div class="content">
{{if $repo}}
{{if $repo.IsPrivate}}
<span class="text gold icon">{{svg "octicon-lock"}}</span>
{{else if $repo.IsFork}}
<span class="icon">{{svg "octicon-repo-forked"}}</span>
{{else if $repo.IsMirror}}
<span class="icon">{{svg "octicon-mirror"}}</span>
{{else if $repo.IsTemplate}}
<span class="icon">{{svg "octicon-repo-template"}}</span>
{{else}}
<span class="icon">{{svg "octicon-repo"}}</span>
{{end}}
<a class="name" href="{{AppSubUrl}}/{{$repo.OwnerName}}/{{$repo.Name}}">{{$repo.OwnerName}}/{{$repo.Name}}</a>
<span>{{SizeFmt $repo.Size}}</span>
{{if $repo.IsFork}}
{{$.i18n.Tr "repo.forked_from"}}
<span><a href="{{AppSubUrl}}/{{$repo.BaseRepo.OwnerName}}/{{$repo.BaseRepo.Name}}">{{$repo.BaseRepo.OwnerName}}/{{$repo.BaseRepo.Name}}</a></span>
{{end}}
{{else}}
<span class="icon">{{svg "octicon-file-directory"}}</span>
<span class="name">{{$.Owner.Name}}/{{$dir}}</span>
<div class="right floated content">
{{if $.allowAdopt}}
<button class="ui button submit tiny green adopt show-modal" data-modal="#adopt-unadopted-modal-{{$dirI}}"><span class="icon">{{svg "octicon-plus"}}</span><span class="label">{{$.i18n.Tr "repo.adopt_preexisting_label"}}</span></button>
<div class="ui basic modal" id="adopt-unadopted-modal-{{$dirI}}">
<i class="close icon"></i>
<div class="header">
<span class="label">{{$.i18n.Tr "repo.adopt_preexisting"}}</span>
</div>
<div class="content">
<p>{{$.i18n.Tr "repo.adopt_preexisting_content" $dir}}</p>
</div>
<form class="ui form" method="POST" action="{{AppSubUrl}}/user/settings/repos/unadopted">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{$dir}}">
<input type="hidden" name="action" value="adopt">
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{$.i18n.Tr "modal.no"}}
</div>
<button class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{$.i18n.Tr "modal.yes"}}
</button>
</div>
</form>
</div>
{{end}}
{{if $.allowDelete}}
<button class="ui button submit tiny red delete show-modal" data-modal="#delete-unadopted-modal-{{$dirI}}"><span class="icon">{{svg "octicon-x"}}</span><span class="label">{{$.i18n.Tr "repo.delete_preexisting_label"}}</span></button>
<div class="ui basic modal" id="delete-unadopted-modal-{{$dirI}}">
<i class="close icon"></i>
<div class="header">
<span class="label">{{$.i18n.Tr "repo.delete_preexisting"}}</span>
</div>
<div class="content">
<p>{{$.i18n.Tr "repo.delete_preexisting_content" $dir}}</p>
</div>
<form class="ui form" method="POST" action="{{AppSubUrl}}/user/settings/repos/unadopted">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{$dir}}">
<input type="hidden" name="action" value="delete">
<div class="actions">
<div class="ui red basic inverted cancel button">
<i class="remove icon"></i>
{{$.i18n.Tr "modal.no"}}
</div>
<button class="ui green basic inverted ok button">
<i class="checkmark icon"></i>
{{$.i18n.Tr "modal.yes"}}
</button>
</div>
</form>
</div>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{template "base/paginate" .}}
{{else}}
<div class="item">
{{.i18n.Tr "settings.repos_none"}}
</div>
{{end}}
{{else}}
{{if .Repos}} {{if .Repos}}
<div class="ui middle aligned divided list"> <div class="ui middle aligned divided list">
{{range .Repos}} {{range .Repos}}
@ -18,24 +114,28 @@
<span class="iconFloat">{{svg "octicon-repo-forked"}}</span> <span class="iconFloat">{{svg "octicon-repo-forked"}}</span>
{{else if .IsMirror}} {{else if .IsMirror}}
<span class="iconFloat">{{svg "octicon-mirror"}}</span> <span class="iconFloat">{{svg "octicon-mirror"}}</span>
{{else if .IsTemplate}}
<span class="iconFloat">{{svg "octicon-repo-template"}}</span>
{{else}} {{else}}
<span class="iconFloat">{{svg "octicon-repo"}}</span> <span class="iconFloat">{{svg "octicon-repo"}}</span>
{{end}} {{end}}
<a class="name" href="{{AppSubUrl}}/{{$.Owner.Name}}/{{.Name}}">{{$.Owner.Name}}/{{.Name}}</a> <a class="name" href="{{AppSubUrl}}/{{$.OwnerName}}/{{.Name}}">{{$.OwnerName}}/{{.Name}}</a>
<span>{{SizeFmt .Size}}</span> <span>{{SizeFmt .Size}}</span>
{{if .IsFork}} {{if .IsFork}}
{{$.i18n.Tr "repo.forked_from"}} {{$.i18n.Tr "repo.forked_from"}}
<span><a href="{{AppSubUrl}}/{{.BaseRepo.Owner.Name}}/{{.BaseRepo.Name}}">{{.BaseRepo.Owner.Name}}/{{.BaseRepo.Name}}</a></span> <span><a href="{{AppSubUrl}}/{{.BaseRepo.OwnerName}}/{{.BaseRepo.Name}}">{{.BaseRepo.OwnerName}}/{{.BaseRepo.Name}}</a></span>
{{end}} {{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
</div> </div>
{{template "base/paginate" .}}
{{else}} {{else}}
<div class="item"> <div class="item">
{{.i18n.Tr "settings.repos_none"}} {{.i18n.Tr "settings.repos_none"}}
</div> </div>
{{end}} {{end}}
{{end}}
</div> </div>
</div> </div>
</div> </div>

View file

@ -30,6 +30,16 @@
form tbody button[type='submit'] { form tbody button[type='submit'] {
padding: 5px 8px; padding: 5px 8px;
} }
}
.settings .button.adopt,
.settings .button.delete {
margin-top: -15px;
margin-bottom: -15px;
.label {
vertical-align: middle;
}
} }
.ui.header, .ui.header,

View file

@ -135,6 +135,15 @@
} }
} }
.button.adopt,
.button.delete {
margin-top: -15px;
margin-bottom: -15px;
.label {
vertical-align: middle;
}
}
&.link-account:not(.icon) { &.link-account:not(.icon) {
padding-top: 15px; padding-top: 15px;
padding-bottom: 5px; padding-bottom: 5px;