初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
func deleteFailedAdoptRepository(repoID int64) error {
|
||||
return db.WithTx(graceful.GetManager().ShutdownContext(), func(ctx context.Context) error {
|
||||
if err := deleteDBRepository(ctx, repoID); err != nil {
|
||||
return fmt.Errorf("deleteDBRepository: %w", err)
|
||||
}
|
||||
if err := git_model.DeleteRepoBranches(ctx, repoID); err != nil {
|
||||
return fmt.Errorf("deleteRepoBranches: %w", err)
|
||||
}
|
||||
return repo_model.DeleteRepoReleases(ctx, repoID)
|
||||
})
|
||||
}
|
||||
|
||||
// AdoptRepository adopts pre-existing repository files for the user/organization.
|
||||
func AdoptRepository(ctx context.Context, doer, owner *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return nil, repo_model.ErrReachLimitOfRepo{
|
||||
Limit: owner.MaxRepoCreation,
|
||||
}
|
||||
}
|
||||
|
||||
repo := &repo_model.Repository{
|
||||
OwnerID: owner.ID,
|
||||
Owner: owner,
|
||||
OwnerName: owner.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: repo_model.RepositoryBeingMigrated,
|
||||
IsEmpty: !opts.AutoInit,
|
||||
}
|
||||
|
||||
// 1 - create the repository database operations first
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return createRepositoryInDB(ctx, doer, owner, repo, false)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// last - clean up if something goes wrong
|
||||
// WARNING: Don't override all later err with local variables
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// we can not use `ctx` because it may be canceled or timed out
|
||||
if errDel := deleteFailedAdoptRepository(repo.ID); errDel != nil {
|
||||
log.Error("Failed to delete repository %s that could not be adopted: %v", repo.FullName(), errDel)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-fetch the repository from database before updating it (else it would
|
||||
// override changes that were done earlier with sql)
|
||||
if repo, err = repo_model.GetRepositoryByID(ctx, repo.ID); err != nil {
|
||||
return nil, fmt.Errorf("getRepositoryByID: %w", err)
|
||||
}
|
||||
|
||||
// 2 - adopt the repository from disk
|
||||
if err = adoptRepository(ctx, repo, opts.DefaultBranch); err != nil {
|
||||
return nil, fmt.Errorf("adoptRepository: %w", err)
|
||||
}
|
||||
|
||||
// 3 - Update the git repository
|
||||
if err = updateGitRepoAfterCreate(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("updateGitRepoAfterCreate: %w", err)
|
||||
}
|
||||
|
||||
// 4 - update repository status
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err = repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "status"); err != nil {
|
||||
return nil, fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
|
||||
notify_service.AdoptRepository(ctx, doer, owner, repo)
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func adoptRepository(ctx context.Context, repo *repo_model.Repository, defaultBranch string) (err error) {
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repo.FullName(), err)
|
||||
return err
|
||||
}
|
||||
if !isExist {
|
||||
return fmt.Errorf("adoptRepository: path does not already exist: %s", repo.FullName())
|
||||
}
|
||||
|
||||
if err := gitrepo.CreateDelegateHooks(ctx, repo); err != nil {
|
||||
return fmt.Errorf("createDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
repo.IsEmpty = false
|
||||
|
||||
if len(defaultBranch) > 0 {
|
||||
repo.DefaultBranch = defaultBranch
|
||||
|
||||
if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("setDefaultBranch: %w", err)
|
||||
}
|
||||
} else {
|
||||
repo.DefaultBranch, err = gitrepo.GetDefaultBranch(ctx, repo)
|
||||
if err != nil {
|
||||
repo.DefaultBranch = setting.Repository.DefaultBranch
|
||||
if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("setDefaultBranch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't bother looking this repo in the context it won't be there
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("openRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
if _, _, err = repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, 0); err != nil {
|
||||
return fmt.Errorf("SyncRepoBranchesWithRepo: %w", err)
|
||||
}
|
||||
|
||||
if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
|
||||
return fmt.Errorf("SyncReleasesWithTags: %w", err)
|
||||
}
|
||||
|
||||
branches, _ := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{
|
||||
RepoID: repo.ID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IsDeletedBranch: optional.Some(false),
|
||||
})
|
||||
|
||||
found := false
|
||||
hasDefault := false
|
||||
hasMaster := false
|
||||
hasMain := 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
|
||||
} else if branch == "main" {
|
||||
hasMain = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if hasDefault {
|
||||
repo.DefaultBranch = setting.Repository.DefaultBranch
|
||||
} else if hasMaster {
|
||||
repo.DefaultBranch = "master"
|
||||
} else if hasMain {
|
||||
repo.DefaultBranch = "main"
|
||||
} else if len(branches) > 0 {
|
||||
repo.DefaultBranch = branches[0]
|
||||
} else {
|
||||
repo.IsEmpty = true
|
||||
repo.DefaultBranch = setting.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("setDefaultBranch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_empty", "default_branch"); err != nil {
|
||||
return fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUnadoptedRepository deletes unadopted repository files from the filesystem
|
||||
func DeleteUnadoptedRepository(ctx context.Context, doer, u *user_model.User, repoName string) error {
|
||||
if err := repo_model.IsUsableRepoName(repoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relativePath := repo_model.RelativePath(u.Name, repoName)
|
||||
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(relativePath))
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", relativePath, err)
|
||||
return err
|
||||
}
|
||||
if !exist {
|
||||
return repo_model.ErrRepoNotExist{
|
||||
OwnerName: u.Name,
|
||||
Name: repoName,
|
||||
}
|
||||
}
|
||||
|
||||
if exist, err := repo_model.IsRepositoryModelExist(ctx, u, repoName); err != nil {
|
||||
return err
|
||||
} else if exist {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repoName,
|
||||
}
|
||||
}
|
||||
|
||||
return gitrepo.DeleteRepository(ctx, repo_model.StorageRepo(relativePath))
|
||||
}
|
||||
|
||||
type unadoptedRepositories struct {
|
||||
repositories []string
|
||||
count int64
|
||||
start, end int64
|
||||
}
|
||||
|
||||
func (unadopted *unadoptedRepositories) add(repository string) {
|
||||
if unadopted.count >= unadopted.start && unadopted.count < unadopted.end {
|
||||
unadopted.repositories = append(unadopted.repositories, repository)
|
||||
}
|
||||
unadopted.count++
|
||||
}
|
||||
|
||||
func checkUnadoptedRepositories(ctx context.Context, userName string, repoNamesToCheck []string, unadopted *unadoptedRepositories) error {
|
||||
if len(repoNamesToCheck) == 0 {
|
||||
return nil
|
||||
}
|
||||
ctxUser, err := user_model.GetUserByName(ctx, userName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
log.Debug("Missing user: %s", userName)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
repos, _, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{
|
||||
Actor: ctxUser,
|
||||
Private: true,
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: len(repoNamesToCheck),
|
||||
}, LowerNames: repoNamesToCheck,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == len(repoNamesToCheck) {
|
||||
return nil
|
||||
}
|
||||
repoNames := make(container.Set[string], len(repos))
|
||||
for _, repo := range repos {
|
||||
repoNames.Add(repo.LowerName)
|
||||
}
|
||||
for _, repoName := range repoNamesToCheck {
|
||||
if !repoNames.Contains(repoName) {
|
||||
unadopted.add(path.Join(userName, repoName)) // These are not used as filepaths - but as reponames - therefore use path.Join not filepath.Join
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUnadoptedRepositories lists all the unadopted repositories that match the provided query
|
||||
func ListUnadoptedRepositories(ctx context.Context, query string, opts *db.ListOptions) ([]string, int64, error) {
|
||||
opts.SetDefaultValues()
|
||||
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 expression '%s' (skipped): %v", qsplit[0], err)
|
||||
}
|
||||
if len(qsplit) > 1 {
|
||||
globRepo, err = glob.Compile(qsplit[1])
|
||||
if err != nil {
|
||||
log.Info("Invalid glob expression '%s' (skipped): %v", qsplit[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
var repoNamesToCheck []string
|
||||
|
||||
start := int64((opts.Page - 1) * opts.PageSize)
|
||||
unadopted := &unadoptedRepositories{
|
||||
repositories: make([]string, 0, opts.PageSize),
|
||||
start: start,
|
||||
end: start + int64(opts.PageSize),
|
||||
count: 0,
|
||||
}
|
||||
|
||||
var userName string
|
||||
|
||||
// We're going to iterate by pagesize.
|
||||
root := filepath.Clean(setting.RepoRootPath)
|
||||
if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() || path == root {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := d.Name()
|
||||
|
||||
if !strings.ContainsRune(path[len(root)+1:], filepath.Separator) {
|
||||
// Got a new user
|
||||
if err = checkUnadoptedRepositories(ctx, userName, repoNamesToCheck, unadopted); err != nil {
|
||||
return err
|
||||
}
|
||||
repoNamesToCheck = repoNamesToCheck[:0]
|
||||
|
||||
if !globUser.Match(name) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
userName = name
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(name, ".git") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
name = name[:len(name)-4]
|
||||
if repo_model.IsUsableRepoName(name) != nil || strings.ToLower(name) != name || !globRepo.Match(name) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
repoNamesToCheck = append(repoNamesToCheck, name)
|
||||
if len(repoNamesToCheck) >= setting.Database.IterateBufferSize {
|
||||
if err = checkUnadoptedRepositories(ctx, userName, repoNamesToCheck, unadopted); err != nil {
|
||||
return err
|
||||
}
|
||||
repoNamesToCheck = repoNamesToCheck[:0]
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := checkUnadoptedRepositories(ctx, userName, repoNamesToCheck, unadopted); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return unadopted.repositories, unadopted.count, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckUnadoptedRepositories_Add(t *testing.T) {
|
||||
const start = 10
|
||||
const end = 20
|
||||
unadopted := &unadoptedRepositories{
|
||||
start: start,
|
||||
end: end,
|
||||
count: 0,
|
||||
}
|
||||
|
||||
const total = 30
|
||||
for range total {
|
||||
unadopted.add("something")
|
||||
}
|
||||
|
||||
assert.EqualValues(t, total, unadopted.count)
|
||||
assert.Len(t, unadopted.repositories, end-start)
|
||||
}
|
||||
|
||||
func TestCheckUnadoptedRepositories(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
//
|
||||
// Non existent user
|
||||
//
|
||||
unadopted := &unadoptedRepositories{start: 0, end: 100}
|
||||
err := checkUnadoptedRepositories(t.Context(), "notauser", []string{"repo"}, unadopted)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, unadopted.repositories)
|
||||
//
|
||||
// Unadopted repository is returned
|
||||
// Existing (adopted) repository is not returned
|
||||
//
|
||||
userName := "user2"
|
||||
repoName := "repo2"
|
||||
unadoptedRepoName := "unadopted"
|
||||
unadopted = &unadoptedRepositories{start: 0, end: 100}
|
||||
err = checkUnadoptedRepositories(t.Context(), userName, []string{repoName, unadoptedRepoName}, unadopted)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{path.Join(userName, unadoptedRepoName)}, unadopted.repositories)
|
||||
//
|
||||
// Existing (adopted) repository is not returned
|
||||
//
|
||||
unadopted = &unadoptedRepositories{start: 0, end: 100}
|
||||
err = checkUnadoptedRepositories(t.Context(), userName, []string{repoName}, unadopted)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, unadopted.repositories)
|
||||
assert.Zero(t, unadopted.count)
|
||||
}
|
||||
|
||||
func TestListUnadoptedRepositories_ListOptions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
username := "user2"
|
||||
unadoptedList := []string{path.Join(username, "unadopted1"), path.Join(username, "unadopted2")}
|
||||
for _, unadopted := range unadoptedList {
|
||||
_ = os.Mkdir(filepath.Join(setting.RepoRootPath, unadopted+".git"), 0o755)
|
||||
}
|
||||
|
||||
opts := db.ListOptions{Page: 1, PageSize: 1}
|
||||
repoNames, count, err := ListUnadoptedRepositories(t.Context(), "", &opts)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 2, count)
|
||||
assert.Equal(t, unadoptedList[0], repoNames[0])
|
||||
|
||||
opts = db.ListOptions{Page: 2, PageSize: 1}
|
||||
repoNames, count, err = ListUnadoptedRepositories(t.Context(), "", &opts)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 2, count)
|
||||
assert.Equal(t, unadoptedList[1], repoNames[0])
|
||||
}
|
||||
|
||||
func TestAdoptRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// a successful adopt
|
||||
destDir := filepath.Join(setting.RepoRootPath, user2.Name, "test-adopt.git")
|
||||
assert.NoError(t, unittest.SyncDirs(filepath.Join(setting.RepoRootPath, user2.Name, "repo1.git"), destDir))
|
||||
|
||||
adoptedRepo, err := AdoptRepository(t.Context(), user2, user2, CreateRepoOptions{Name: "test-adopt"})
|
||||
assert.NoError(t, err)
|
||||
repoTestAdopt := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "test-adopt"})
|
||||
assert.Equal(t, "sha1", repoTestAdopt.ObjectFormatName)
|
||||
|
||||
// just delete the adopted repo's db records
|
||||
err = deleteFailedAdoptRepository(adoptedRepo.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerName: user2.Name, Name: "test-adopt"})
|
||||
|
||||
// a failed adopt because some mock data
|
||||
// remove the hooks directory and create a file so that we cannot create the hooks successfully
|
||||
_ = os.RemoveAll(filepath.Join(destDir, "hooks", "update.d"))
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(destDir, "hooks", "update.d"), []byte("tests"), os.ModePerm))
|
||||
|
||||
adoptedRepo, err = AdoptRepository(t.Context(), user2, user2, CreateRepoOptions{Name: "test-adopt"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, adoptedRepo)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerName: user2.Name, Name: "test-adopt"})
|
||||
|
||||
exist, err := util.IsExist(repo_model.RepoPath(user2.Name, "test-adopt"))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist) // the repository should be still in the disk
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/util"
|
||||
gitea_context "gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// ArchiveRequest defines the parameters of an archive request, which notably
|
||||
// includes the specific repository being archived as well as the commit, the
|
||||
// name by which it was requested, and the kind of archive being requested.
|
||||
// This is entirely opaque to external entities, though, and mostly used as a
|
||||
// handle elsewhere.
|
||||
type ArchiveRequest struct {
|
||||
Repo *repo_model.Repository
|
||||
Type repo_model.ArchiveType
|
||||
CommitID string
|
||||
Paths []string
|
||||
|
||||
archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id"
|
||||
}
|
||||
|
||||
// NewRequest creates an archival request, based on the URI. The
|
||||
// resulting ArchiveRequest is suitable for being passed to Await()
|
||||
// if it's determined that the request still needs to be satisfied.
|
||||
func NewRequest(repo *repo_model.Repository, gitRepo *git.Repository, archiveRefExt string, paths []string) (*ArchiveRequest, error) {
|
||||
// here the archiveRefShortName is not a clear ref, it could be a tag, branch or commit id
|
||||
archiveRefShortName, archiveType := repo_model.SplitArchiveNameType(archiveRefExt)
|
||||
if archiveType == repo_model.ArchiveUnknown {
|
||||
return nil, util.NewInvalidArgumentErrorf("unknown format: %s", archiveRefExt)
|
||||
}
|
||||
if archiveType == repo_model.ArchiveBundle && len(paths) != 0 {
|
||||
return nil, util.NewInvalidArgumentErrorf("cannot specify paths when requesting a bundle")
|
||||
}
|
||||
|
||||
// Get corresponding commit.
|
||||
commitID, err := gitRepo.ConvertToGitID(archiveRefShortName)
|
||||
if err != nil {
|
||||
return nil, util.NewNotExistErrorf("unrecognized repository reference: %s", archiveRefShortName)
|
||||
}
|
||||
|
||||
r := &ArchiveRequest{Repo: repo, archiveRefShortName: archiveRefShortName, Type: archiveType, Paths: paths}
|
||||
r.CommitID = commitID.String()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetArchiveName returns the name of the caller, based on the ref used by the
|
||||
// caller to create this request.
|
||||
func (aReq *ArchiveRequest) GetArchiveName() string {
|
||||
return strings.ReplaceAll(aReq.archiveRefShortName, "/", "-") + "." + aReq.Type.String()
|
||||
}
|
||||
|
||||
// Await awaits the completion of an ArchiveRequest. If the archive has
|
||||
// already been prepared the method returns immediately. Otherwise, an archiver
|
||||
// process will be started and its completion awaited. On success the returned
|
||||
// RepoArchiver may be used to download the archive. Note that even if the
|
||||
// context is cancelled/times out a started archiver will still continue to run
|
||||
// in the background.
|
||||
func (aReq *ArchiveRequest) Await(ctx context.Context) (*repo_model.RepoArchiver, error) {
|
||||
archiver, err := repo_model.GetRepoArchiver(ctx, aReq.Repo.ID, aReq.Type, aReq.CommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("models.GetRepoArchiver: %w", err)
|
||||
}
|
||||
|
||||
if archiver != nil && archiver.Status == repo_model.ArchiverReady {
|
||||
// Archive already generated, we're done.
|
||||
return archiver, nil
|
||||
}
|
||||
|
||||
if err := StartArchive(aReq); err != nil {
|
||||
return nil, fmt.Errorf("archiver.StartArchive: %w", err)
|
||||
}
|
||||
|
||||
poll := time.NewTicker(time.Second * 1)
|
||||
defer poll.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-graceful.GetManager().HammerContext().Done():
|
||||
// System stopped.
|
||||
return nil, graceful.GetManager().HammerContext().Err()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-poll.C:
|
||||
archiver, err = repo_model.GetRepoArchiver(ctx, aReq.Repo.ID, aReq.Type, aReq.CommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repo_model.GetRepoArchiver: %w", err)
|
||||
}
|
||||
if archiver != nil && archiver.Status == repo_model.ArchiverReady {
|
||||
return archiver, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream satisfies the ArchiveRequest being passed in. Processing
|
||||
// will occur directly in this routine.
|
||||
func (aReq *ArchiveRequest) Stream(ctx context.Context, w io.Writer) error {
|
||||
if aReq.Type == repo_model.ArchiveBundle {
|
||||
return gitrepo.CreateBundle(
|
||||
ctx,
|
||||
aReq.Repo,
|
||||
aReq.CommitID,
|
||||
w,
|
||||
)
|
||||
}
|
||||
return gitrepo.CreateArchive(
|
||||
ctx,
|
||||
aReq.Repo,
|
||||
aReq.Type.String(),
|
||||
w,
|
||||
setting.Repository.PrefixArchiveFiles,
|
||||
aReq.CommitID,
|
||||
aReq.Paths,
|
||||
)
|
||||
}
|
||||
|
||||
// doArchive satisfies the ArchiveRequest being passed in. Processing
|
||||
// will occur in a separate goroutine, as this phase may take a while to
|
||||
// complete. If the archive already exists, doArchive will not do
|
||||
// anything. In all cases, the caller should be examining the *ArchiveRequest
|
||||
// being returned for completion, as it may be different than the one they passed
|
||||
// in.
|
||||
func doArchive(ctx context.Context, r *ArchiveRequest) (*repo_model.RepoArchiver, error) {
|
||||
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("ArchiveRequest[%s]: %s", r.Repo.FullName(), r.GetArchiveName()))
|
||||
defer finished()
|
||||
|
||||
archiver, err := repo_model.GetRepoArchiver(ctx, r.Repo.ID, r.Type, r.CommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if archiver != nil {
|
||||
// FIXME: If another process are generating it, we think it's not ready and just return
|
||||
// Or we should wait until the archive generated.
|
||||
if archiver.Status == repo_model.ArchiverGenerating {
|
||||
return nil, nil //nolint:nilnil // return nil because the archive is still being generated
|
||||
}
|
||||
} else {
|
||||
archiver = &repo_model.RepoArchiver{
|
||||
RepoID: r.Repo.ID,
|
||||
Type: r.Type,
|
||||
CommitID: r.CommitID,
|
||||
Status: repo_model.ArchiverGenerating,
|
||||
}
|
||||
if err := db.Insert(ctx, archiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rPath := archiver.RelativePath()
|
||||
_, err = storage.RepoArchives.Stat(rPath)
|
||||
if err == nil {
|
||||
if archiver.Status == repo_model.ArchiverGenerating {
|
||||
archiver.Status = repo_model.ArchiverReady
|
||||
if err = repo_model.UpdateRepoArchiverStatus(ctx, archiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return archiver, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("unable to stat archive: %w", err)
|
||||
}
|
||||
|
||||
rd, w := io.Pipe()
|
||||
defer func() {
|
||||
_ = w.Close()
|
||||
_ = rd.Close()
|
||||
}()
|
||||
done := make(chan error, 1) // Ensure that there is some capacity which will ensure that the goroutine below can always finish
|
||||
|
||||
go func(done chan error, w *io.PipeWriter, archiveReq *ArchiveRequest) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- fmt.Errorf("%v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := archiveReq.Stream(ctx, w)
|
||||
_ = w.CloseWithError(err)
|
||||
done <- err
|
||||
}(done, w, r)
|
||||
|
||||
// TODO: add lfs data to zip
|
||||
// TODO: add submodule data to zip
|
||||
|
||||
if _, err := storage.RepoArchives.Save(rPath, rd, -1); err != nil {
|
||||
return nil, fmt.Errorf("unable to write archive: %w", err)
|
||||
}
|
||||
|
||||
err = <-done
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if archiver.Status == repo_model.ArchiverGenerating {
|
||||
archiver.Status = repo_model.ArchiverReady
|
||||
if err = repo_model.UpdateRepoArchiverStatus(ctx, archiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return archiver, nil
|
||||
}
|
||||
|
||||
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest]
|
||||
|
||||
// Init initializes archiver
|
||||
func Init(ctx context.Context) error {
|
||||
handler := func(items ...*ArchiveRequest) []*ArchiveRequest {
|
||||
for _, archiveReq := range items {
|
||||
log.Trace("ArchiverData Process: %#v", archiveReq)
|
||||
if archiver, err := doArchive(ctx, archiveReq); err != nil {
|
||||
log.Error("Archive %v failed: %v", archiveReq, err)
|
||||
} else {
|
||||
log.Trace("ArchiverData Success: %#v", archiver)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
archiverQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "repo-archive", handler)
|
||||
if archiverQueue == nil {
|
||||
return errors.New("unable to create repo-archive queue")
|
||||
}
|
||||
go graceful.GetManager().RunWithCancel(archiverQueue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartArchive push the archive request to the queue
|
||||
func StartArchive(request *ArchiveRequest) error {
|
||||
has, err := archiverQueue.Has(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
return archiverQueue.Push(request)
|
||||
}
|
||||
|
||||
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {
|
||||
if _, err := db.DeleteByID[repo_model.RepoArchiver](ctx, archiver.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
p := archiver.RelativePath()
|
||||
if err := storage.RepoArchives.Delete(p); err != nil {
|
||||
log.Error("delete repo archive file failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOldRepositoryArchives deletes old repository archives.
|
||||
func DeleteOldRepositoryArchives(ctx context.Context, olderThan time.Duration) error {
|
||||
log.Trace("Doing: ArchiveCleanup")
|
||||
|
||||
for {
|
||||
archivers, err := db.Find[repo_model.RepoArchiver](ctx, repo_model.FindRepoArchiversOption{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: 100,
|
||||
Page: 1,
|
||||
},
|
||||
OlderThan: olderThan,
|
||||
})
|
||||
if err != nil {
|
||||
log.Trace("Error: ArchiveClean: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, archiver := range archivers {
|
||||
if err := deleteOldRepoArchiver(ctx, archiver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(archivers) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace("Finished: ArchiveCleanup")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRepositoryArchives deletes all repositories' archives.
|
||||
func DeleteRepositoryArchives(ctx context.Context) error {
|
||||
if err := repo_model.DeleteAllRepoArchives(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.Clean(storage.RepoArchives)
|
||||
}
|
||||
|
||||
func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error {
|
||||
// Add nix format link header so tarballs lock correctly:
|
||||
// https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md
|
||||
ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`,
|
||||
archiveReq.Repo.APIURL(),
|
||||
archiveReq.CommitID,
|
||||
archiveReq.Type.String(),
|
||||
archiveReq.CommitID,
|
||||
))
|
||||
downloadName := archiveReq.Repo.Name + "-" + archiveReq.GetArchiveName()
|
||||
|
||||
if setting.Repository.StreamArchives || len(archiveReq.Paths) > 0 {
|
||||
// the header must be set before starting streaming even an error would occur,
|
||||
// because errors may happen in git command and such cases aren't in our control.
|
||||
httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{Filename: downloadName})
|
||||
if err := archiveReq.Stream(ctx, ctx.Resp); err != nil && !ctx.Written() {
|
||||
if gitcmd.StderrHasPrefix(err, "fatal: pathspec") {
|
||||
return util.NewInvalidArgumentErrorf("path doesn't exist or is invalid")
|
||||
}
|
||||
return fmt.Errorf("archive repo %s: failed to stream: %w", archiveReq.Repo.FullName(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
archiver, err := archiveReq.Await(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive repo %s: failed to await: %w", archiveReq.Repo.FullName(), err)
|
||||
}
|
||||
|
||||
rPath := archiver.RelativePath()
|
||||
if setting.RepoArchive.Storage.ServeDirect() {
|
||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||
u, err := storage.RepoArchives.ServeDirectURL(rPath, downloadName, ctx.Req.Method, nil)
|
||||
if u != nil && err == nil {
|
||||
ctx.Redirect(u.String())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fr, err := storage.RepoArchives.Open(rPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive repo %s: failed to open archive file: %w", archiveReq.Repo.FullName(), err)
|
||||
}
|
||||
defer fr.Close()
|
||||
|
||||
ctx.ServeContent(fr, gitea_context.ServeHeaderOptions{
|
||||
Filename: downloadName,
|
||||
LastModified: archiver.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestArchive_Basic(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "user27/repo49")
|
||||
firstCommit, secondCommit := "51f84af23134", "aacbdfe9e1c4"
|
||||
|
||||
contexttest.LoadRepo(t, ctx, 49)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
bogusReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.Equal(t, firstCommit+".zip", bogusReq.GetArchiveName())
|
||||
|
||||
// Check a series of bogus requests.
|
||||
// Step 1, valid commit with a bad extension.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".unknown", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
// Step 2, missing commit.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "dbffff.zip", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
// Step 3, doesn't look like branch/tag/commit.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "db.zip", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "master.zip", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.Equal(t, "master.zip", bogusReq.GetArchiveName())
|
||||
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "test/archive.zip", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.Equal(t, "test-archive.zip", bogusReq.GetArchiveName())
|
||||
|
||||
// Now two valid requests, firstCommit with valid extensions.
|
||||
zipReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, zipReq)
|
||||
|
||||
tgzReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".tar.gz", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tgzReq)
|
||||
|
||||
secondReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".bundle", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, secondReq)
|
||||
|
||||
inFlight := make([]*ArchiveRequest, 3)
|
||||
inFlight[0] = zipReq
|
||||
inFlight[1] = tgzReq
|
||||
inFlight[2] = secondReq
|
||||
|
||||
doArchive(t.Context(), zipReq)
|
||||
doArchive(t.Context(), tgzReq)
|
||||
doArchive(t.Context(), secondReq)
|
||||
|
||||
// Make sure sending an unprocessed request through doesn't affect the queue
|
||||
// count.
|
||||
doArchive(t.Context(), zipReq)
|
||||
|
||||
// Sleep two seconds to make sure the queue doesn't change.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
zipReq2, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
|
||||
assert.NoError(t, err)
|
||||
// This zipReq should match what's sitting in the queue, as we haven't
|
||||
// let it release yet. From the consumer's point of view, this looks like
|
||||
// a long-running archive task.
|
||||
assert.Equal(t, zipReq, zipReq2)
|
||||
|
||||
// We still have the other three stalled at completion, waiting to remove
|
||||
// from archiveInProgress. Try to submit this new one before its
|
||||
// predecessor has cleared out of the queue.
|
||||
doArchive(t.Context(), zipReq2)
|
||||
|
||||
// Now we'll submit a request and TimedWaitForCompletion twice, before and
|
||||
// after we release it. We should trigger both the timeout and non-timeout
|
||||
// cases.
|
||||
timedReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".tar.gz", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, timedReq)
|
||||
doArchive(t.Context(), timedReq)
|
||||
|
||||
zipReq2, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
|
||||
assert.NoError(t, err)
|
||||
// Now, we're guaranteed to have released the original zipReq from the queue.
|
||||
// Ensure that we don't get handed back the released entry somehow, but they
|
||||
// should remain functionally equivalent in all fields. The exception here
|
||||
// is zipReq.cchan, which will be non-nil because it's a completed request.
|
||||
// It's fine to go ahead and set it to nil now.
|
||||
|
||||
assert.Equal(t, zipReq, zipReq2)
|
||||
assert.NotSame(t, zipReq, zipReq2)
|
||||
|
||||
// Same commit, different compression formats should have different names.
|
||||
// Ideally, the extension would match what we originally requested.
|
||||
assert.NotEqual(t, zipReq.GetArchiveName(), tgzReq.GetArchiveName())
|
||||
assert.NotEqual(t, zipReq.GetArchiveName(), secondReq.GetArchiveName())
|
||||
|
||||
t.Run("BadPath", func(t *testing.T) {
|
||||
badRequest, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".tar.gz", []string{"not-a-path"})
|
||||
require.NoError(t, err)
|
||||
err = ServeRepoArchive(ctx.Base, badRequest)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
assert.ErrorContains(t, err, "path doesn't exist or is invalid")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/avatar"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
)
|
||||
|
||||
// UploadAvatar saves custom avatar for repository.
|
||||
// FIXME: split uploads to different subdirs in case we have massive number of repos.
|
||||
func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) error {
|
||||
avatarData, err := avatar.ProcessAvatarImage(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UploadAvatar: failed to process repo avatar image: %w", err)
|
||||
}
|
||||
|
||||
newAvatar := avatar.HashAvatar(repo.ID, data)
|
||||
if repo.Avatar == newAvatar { // upload the same picture
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
oldAvatarPath := repo.CustomAvatarRelativePath()
|
||||
|
||||
// Users can upload the same image to other repo - prefix it with ID
|
||||
// Then repo will be removed - only it avatar file will be removed
|
||||
repo.Avatar = newAvatar
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "avatar"); err != nil {
|
||||
return fmt.Errorf("UploadAvatar: failed to update repository avatar: %w", err)
|
||||
}
|
||||
|
||||
if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error {
|
||||
_, err := w.Write(avatarData)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("UploadAvatar: failed to save repo avatar %s: %w", newAvatar, err)
|
||||
}
|
||||
|
||||
if len(oldAvatarPath) > 0 {
|
||||
if err := storage.RepoAvatars.Delete(oldAvatarPath); err != nil {
|
||||
return fmt.Errorf("UploadAvatar: failed to remove old repo avatar %s: %w", oldAvatarPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAvatar deletes the repos's custom avatar.
|
||||
func DeleteAvatar(ctx context.Context, repo *repo_model.Repository) error {
|
||||
// Avatar not exists
|
||||
if len(repo.Avatar) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
avatarPath := repo.CustomAvatarRelativePath()
|
||||
log.Trace("DeleteAvatar[%d]: %s", repo.ID, avatarPath)
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repo.Avatar = ""
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "avatar"); err != nil {
|
||||
return fmt.Errorf("DeleteAvatar: Update repository avatar: %w", err)
|
||||
}
|
||||
|
||||
if err := storage.RepoAvatars.Delete(avatarPath); err != nil {
|
||||
return fmt.Errorf("DeleteAvatar: Failed to remove %s: %w", avatarPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveRandomAvatars removes the randomly generated avatars that were created for repositories
|
||||
func RemoveRandomAvatars(ctx context.Context) error {
|
||||
return db.Iterate(ctx, nil, func(ctx context.Context, repository *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("before random avatars removed for %s", repository.FullName())
|
||||
default:
|
||||
}
|
||||
stringifiedID := strconv.FormatInt(repository.ID, 10)
|
||||
if repository.Avatar == stringifiedID {
|
||||
return DeleteAvatar(ctx, repository)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// generateAvatar generates the avatar from a template repository
|
||||
func generateAvatar(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
// generate a new different hash, whatever the "hash data" is, it doesn't matter
|
||||
generateRepo.Avatar = avatar.HashAvatar(generateRepo.ID, []byte("new-avatar"))
|
||||
if _, err := storage.Copy(storage.RepoAvatars, generateRepo.CustomAvatarRelativePath(), storage.RepoAvatars, templateRepo.CustomAvatarRelativePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo_model.UpdateRepositoryColsNoAutoTime(ctx, generateRepo, "avatar")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/avatar"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUploadAvatar(t *testing.T) {
|
||||
// Generate image
|
||||
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
var buff bytes.Buffer
|
||||
png.Encode(&buff, myImage)
|
||||
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
|
||||
err := UploadAvatar(t.Context(), repo, buff.Bytes())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, avatar.HashAvatar(10, buff.Bytes()), repo.Avatar)
|
||||
}
|
||||
|
||||
func TestUploadBigAvatar(t *testing.T) {
|
||||
// Generate BIG image
|
||||
myImage := image.NewRGBA(image.Rect(0, 0, 5000, 1))
|
||||
var buff bytes.Buffer
|
||||
png.Encode(&buff, myImage)
|
||||
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
|
||||
err := UploadAvatar(t.Context(), repo, buff.Bytes())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteAvatar(t *testing.T) {
|
||||
// Generate image
|
||||
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
var buff bytes.Buffer
|
||||
png.Encode(&buff, myImage)
|
||||
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
|
||||
err := UploadAvatar(t.Context(), repo, buff.Bytes())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = DeleteAvatar(t.Context(), repo)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Empty(t, repo.Avatar)
|
||||
}
|
||||
|
||||
func TestGenerateAvatar(t *testing.T) {
|
||||
templateRepo := &repo_model.Repository{ID: 10, Avatar: "a"}
|
||||
generateRepo := &repo_model.Repository{ID: 11}
|
||||
_ = generateAvatar(t.Context(), templateRepo, generateRepo)
|
||||
assert.NotEmpty(t, generateRepo.Avatar)
|
||||
assert.NotEqual(t, templateRepo.Avatar, generateRepo.Avatar)
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/queue"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
release_service "gitea.dev/services/release"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// CreateNewBranch creates a new repository branch
|
||||
func CreateNewBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldBranchName, branchName string) (err error) {
|
||||
branch, err := git_model.GetBranch(ctx, repo.ID, oldBranchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return CreateNewBranchFromCommit(ctx, doer, repo, branch.CommitID, branchName)
|
||||
}
|
||||
|
||||
// Branch contains the branch information
|
||||
type Branch struct {
|
||||
DBBranch *git_model.Branch
|
||||
IsProtected bool
|
||||
IsIncluded bool
|
||||
CommitsAhead int
|
||||
CommitsBehind int
|
||||
LatestPullRequest *issues_model.PullRequest
|
||||
MergeMovedOn bool
|
||||
}
|
||||
|
||||
// LoadBranches loads branches from the repository limited by page & pageSize.
|
||||
func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (*Branch, []*Branch, int64, error) {
|
||||
defaultDBBranch, err := git_model.GetBranch(ctx, repo.ID, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
branchOpts := git_model.FindBranchOptions{
|
||||
RepoID: repo.ID,
|
||||
IsDeletedBranch: isDeletedBranch,
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
Keyword: keyword,
|
||||
ExcludeBranchNames: []string{repo.DefaultBranch},
|
||||
}
|
||||
|
||||
dbBranches, totalNumOfBranches, err := db.FindAndCount[git_model.Branch](ctx, branchOpts)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
if err := git_model.BranchList(dbBranches).LoadDeletedBy(ctx); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
if err := git_model.BranchList(dbBranches).LoadPusher(ctx); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
repoIDToRepo := map[int64]*repo_model.Repository{}
|
||||
repoIDToRepo[repo.ID] = repo
|
||||
|
||||
repoIDToGitRepo := map[int64]*git.Repository{}
|
||||
repoIDToGitRepo[repo.ID] = gitRepo
|
||||
|
||||
branches := make([]*Branch, 0, len(dbBranches))
|
||||
for i := range dbBranches {
|
||||
branch, err := loadOneBranch(ctx, repo, dbBranches[i], &rules, repoIDToRepo, repoIDToGitRepo)
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err)
|
||||
}
|
||||
branches = append(branches, branch)
|
||||
}
|
||||
|
||||
// Always add the default branch
|
||||
log.Debug("loadOneBranch: load default: '%s'", defaultDBBranch.Name)
|
||||
defaultBranch, err := loadOneBranch(ctx, repo, defaultDBBranch, &rules, repoIDToRepo, repoIDToGitRepo)
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err)
|
||||
}
|
||||
return defaultBranch, branches, totalNumOfBranches, nil
|
||||
}
|
||||
|
||||
func getDivergenceCacheKey(repoID int64, branchName string) string {
|
||||
return fmt.Sprintf("%d-%s", repoID, branchName)
|
||||
}
|
||||
|
||||
// getDivergenceFromCache gets the divergence from cache
|
||||
func getDivergenceFromCache(repoID int64, branchName string) (*gitrepo.DivergeObject, bool) {
|
||||
data, ok := cache.GetCache().Get(getDivergenceCacheKey(repoID, branchName))
|
||||
res := gitrepo.DivergeObject{
|
||||
Ahead: -1,
|
||||
Behind: -1,
|
||||
}
|
||||
if !ok || data == "" {
|
||||
return &res, false
|
||||
}
|
||||
if err := json.Unmarshal(util.UnsafeStringToBytes(data), &res); err != nil {
|
||||
log.Error("json.UnMarshal failed: %v", err)
|
||||
return &res, false
|
||||
}
|
||||
return &res, true
|
||||
}
|
||||
|
||||
func putDivergenceFromCache(repoID int64, branchName string, divergence *gitrepo.DivergeObject) error {
|
||||
bs, err := json.Marshal(divergence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cache.GetCache().Put(getDivergenceCacheKey(repoID, branchName), util.UnsafeBytesToString(bs), 30*24*60*60)
|
||||
}
|
||||
|
||||
func DelDivergenceFromCache(repoID int64, branchName string) error {
|
||||
return cache.GetCache().Delete(getDivergenceCacheKey(repoID, branchName))
|
||||
}
|
||||
|
||||
// DelRepoDivergenceFromCache deletes all divergence caches of a repository
|
||||
func DelRepoDivergenceFromCache(ctx context.Context, repoID int64) error {
|
||||
dbBranches, err := db.Find[git_model.Branch](ctx, git_model.FindBranchOptions{
|
||||
RepoID: repoID,
|
||||
ListOptions: db.ListOptionsAll,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range dbBranches {
|
||||
if err := DelDivergenceFromCache(repoID, dbBranches[i].Name); err != nil {
|
||||
log.Error("DelDivergenceFromCache: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *git_model.Branch, protectedBranches *git_model.ProtectedBranchRules,
|
||||
repoIDToRepo map[int64]*repo_model.Repository,
|
||||
repoIDToGitRepo map[int64]*git.Repository,
|
||||
) (*Branch, error) {
|
||||
log.Trace("loadOneBranch: '%s'", dbBranch.Name)
|
||||
|
||||
branchName := dbBranch.Name
|
||||
p := protectedBranches.GetFirstMatched(branchName)
|
||||
isProtected := p != nil
|
||||
|
||||
var divergence *gitrepo.DivergeObject
|
||||
|
||||
// it's not default branch
|
||||
if repo.DefaultBranch != dbBranch.Name && !dbBranch.IsDeleted {
|
||||
var cached bool
|
||||
divergence, cached = getDivergenceFromCache(repo.ID, dbBranch.Name)
|
||||
if !cached {
|
||||
var err error
|
||||
divergence, err = gitrepo.GetDivergingCommits(ctx, repo, repo.DefaultBranch, git.BranchPrefix+branchName)
|
||||
if err != nil {
|
||||
log.Error("GetDivergingCommits: %v", err)
|
||||
} else {
|
||||
if err = putDivergenceFromCache(repo.ID, dbBranch.Name, divergence); err != nil {
|
||||
log.Error("putDivergenceFromCache: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if divergence == nil {
|
||||
// tolerate the error that we cannot get divergence
|
||||
divergence = &gitrepo.DivergeObject{Ahead: -1, Behind: -1}
|
||||
}
|
||||
|
||||
pr, err := issues_model.GetLatestPullRequestByHeadInfo(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetLatestPullRequestByHeadInfo: %v", err)
|
||||
}
|
||||
headCommit := dbBranch.CommitID
|
||||
|
||||
mergeMovedOn := false
|
||||
if pr != nil {
|
||||
pr.HeadRepo = repo
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadIssue: %v", err)
|
||||
}
|
||||
if repo, ok := repoIDToRepo[pr.BaseRepoID]; ok {
|
||||
pr.BaseRepo = repo
|
||||
} else if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadBaseRepo: %v", err)
|
||||
} else {
|
||||
repoIDToRepo[pr.BaseRepoID] = pr.BaseRepo
|
||||
}
|
||||
pr.Issue.Repo = pr.BaseRepo
|
||||
|
||||
if pr.HasMerged {
|
||||
baseGitRepo, ok := repoIDToGitRepo[pr.BaseRepoID]
|
||||
if !ok {
|
||||
baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenRepository: %v", err)
|
||||
}
|
||||
defer baseGitRepo.Close()
|
||||
repoIDToGitRepo[pr.BaseRepoID] = baseGitRepo
|
||||
}
|
||||
pullCommit, err := baseGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil && !git.IsErrNotExist(err) {
|
||||
return nil, fmt.Errorf("GetBranchCommitID: %v", err)
|
||||
}
|
||||
if err == nil && headCommit != pullCommit {
|
||||
// the head has moved on from the merge - we shouldn't delete
|
||||
mergeMovedOn = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isIncluded := divergence.Ahead == 0 && repo.DefaultBranch != branchName
|
||||
return &Branch{
|
||||
DBBranch: dbBranch,
|
||||
IsProtected: isProtected,
|
||||
IsIncluded: isIncluded,
|
||||
CommitsAhead: divergence.Ahead,
|
||||
CommitsBehind: divergence.Behind,
|
||||
LatestPullRequest: pr,
|
||||
MergeMovedOn: mergeMovedOn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkBranchName validates branch name with existing repository branches
|
||||
func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error {
|
||||
_, err := gitrepo.WalkReferences(ctx, repo, func(_, refName string) error {
|
||||
branchRefName := strings.TrimPrefix(refName, git.BranchPrefix)
|
||||
switch {
|
||||
case branchRefName == name:
|
||||
return git_model.ErrBranchAlreadyExists{
|
||||
BranchName: name,
|
||||
}
|
||||
// If branchRefName like "a/b" but we want to create a branch named a then we have a conflict
|
||||
case strings.HasPrefix(branchRefName, name+"/"):
|
||||
return git_model.ErrBranchNameConflict{
|
||||
BranchName: branchRefName,
|
||||
}
|
||||
// Conversely if branchRefName like "a" but we want to create a branch named "a/b" then we also have a conflict
|
||||
case strings.HasPrefix(name, branchRefName+"/"):
|
||||
return git_model.ErrBranchNameConflict{
|
||||
BranchName: branchRefName,
|
||||
}
|
||||
case refName == git.TagPrefix+name:
|
||||
return release_service.ErrTagAlreadyExists{
|
||||
TagName: name,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncBranchesToDB sync the branch information in the database.
|
||||
// It will check whether the branches of the repository have never been synced before.
|
||||
// If so, it will sync all branches of the repository.
|
||||
// Otherwise, it will sync the branches that need to be updated.
|
||||
func SyncBranchesToDB(ctx context.Context, repoID, pusherID int64, branchNames, commitIDs []string, getCommit func(commitID string) (*git.Commit, error)) error {
|
||||
// Some designs that make the code look strange but are made for performance optimization purposes:
|
||||
// 1. Sync branches in a batch to reduce the number of DB queries.
|
||||
// 2. Lazy load commit information since it may be not necessary.
|
||||
// 3. Exit early if synced all branches of git repo when there's no branch in DB.
|
||||
// 4. Check the branches in DB if they are already synced.
|
||||
//
|
||||
// If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once.
|
||||
// See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27
|
||||
// For the first batch, it will hit optimization 3.
|
||||
// For other batches, it will hit optimization 4.
|
||||
|
||||
if len(branchNames) != len(commitIDs) {
|
||||
return errors.New("branchNames and commitIDs length not match")
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
branches, err := git_model.GetBranches(ctx, repoID, branchNames, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git_model.GetBranches: %v", err)
|
||||
}
|
||||
|
||||
if len(branches) == 0 {
|
||||
// if user haven't visit UI but directly push to a branch after upgrading from 1.20 -> 1.21,
|
||||
// we cannot simply insert the branch but need to check we have branches or not
|
||||
hasBranch, err := db.Exist[git_model.Branch](ctx, git_model.FindBranchOptions{
|
||||
RepoID: repoID,
|
||||
IsDeletedBranch: optional.Some(false),
|
||||
}.ToConds())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasBranch {
|
||||
if _, err = repo_module.SyncRepoBranches(ctx, repoID, pusherID); err != nil {
|
||||
return fmt.Errorf("repo_module.SyncRepoBranches %d failed: %v", repoID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
branchMap := make(map[string]*git_model.Branch, len(branches))
|
||||
for _, branch := range branches {
|
||||
branchMap[branch.Name] = branch
|
||||
}
|
||||
|
||||
newBranches := make([]*git_model.Branch, 0, len(branchNames))
|
||||
|
||||
for i, branchName := range branchNames {
|
||||
commitID := commitIDs[i]
|
||||
branch, exist := branchMap[branchName]
|
||||
if exist && branch.CommitID == commitID && !branch.IsDeleted {
|
||||
continue
|
||||
}
|
||||
|
||||
commit, err := getCommit(commitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get commit of %s failed: %v", branchName, err)
|
||||
}
|
||||
|
||||
if exist {
|
||||
if _, err := git_model.UpdateBranch(ctx, repoID, pusherID, branchName, commit); err != nil {
|
||||
return fmt.Errorf("git_model.UpdateBranch %d:%s failed: %v", repoID, branchName, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// if database have branches but not this branch, it means this is a new branch
|
||||
newBranches = append(newBranches, &git_model.Branch{
|
||||
RepoID: repoID,
|
||||
Name: branchName,
|
||||
CommitID: commit.ID.String(),
|
||||
CommitMessage: commit.MessageTitle(),
|
||||
PusherID: pusherID,
|
||||
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
|
||||
})
|
||||
}
|
||||
|
||||
if len(newBranches) > 0 {
|
||||
return db.Insert(ctx, newBranches)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNewBranchFromCommit creates a new repository branch
|
||||
func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commitID, branchName string) (err error) {
|
||||
err = repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if branch name can be used
|
||||
if err := checkBranchName(ctx, repo, branchName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gitrepo.Push(ctx, repo, repo, git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", commitID, git.BranchPrefix, branchName),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
}); err != nil {
|
||||
if git.IsErrPushOutOfDate(err) || git.IsErrPushRejected(err) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("push: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameBranch rename a branch
|
||||
func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, from, to string) (string, error) {
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if from == to {
|
||||
return "target_exist", nil
|
||||
}
|
||||
|
||||
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, to); exist {
|
||||
return "target_exist", nil
|
||||
}
|
||||
|
||||
fromBranch, err := git_model.GetBranch(ctx, repo.ID, from)
|
||||
if err != nil {
|
||||
if git_model.IsErrBranchNotExist(err) {
|
||||
return "from_not_exist", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, doer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isDefault := from == repo.DefaultBranch
|
||||
if isDefault && !perm.IsAdmin() {
|
||||
return "", repo_model.ErrUserDoesNotHaveAccessToRepo{
|
||||
UserID: doer.ID,
|
||||
RepoName: repo.LowerName,
|
||||
}
|
||||
}
|
||||
|
||||
// If from == rule name, admins are allowed to modify them.
|
||||
if protectedBranch, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, from); err != nil {
|
||||
return "", err
|
||||
} else if protectedBranch != nil && !perm.IsAdmin() {
|
||||
return "", repo_model.ErrUserDoesNotHaveAccessToRepo{
|
||||
UserID: doer.ID,
|
||||
RepoName: repo.LowerName,
|
||||
}
|
||||
}
|
||||
|
||||
// We also need to check if "to" matches with a protected branch rule.
|
||||
rule, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, to)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rule != nil && !rule.CanUserPush(ctx, doer) {
|
||||
return "", git_model.ErrBranchIsProtected
|
||||
}
|
||||
|
||||
if err := git_model.RenameBranch(ctx, repo, from, to, func(ctx context.Context, isDefault bool) error {
|
||||
err2 := gitrepo.RenameBranch(ctx, repo, from, to)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
|
||||
if isDefault {
|
||||
// if default branch changed, we need to delete all schedules and cron jobs
|
||||
if err := actions_model.DeleteScheduleTaskByRepo(ctx, repo.ID); err != nil {
|
||||
log.Error("DeleteCronTaskByRepo: %v", err)
|
||||
}
|
||||
// cancel running cron jobs of this repository and delete old schedules
|
||||
if err := actions_service.CancelPreviousJobs(
|
||||
ctx,
|
||||
repo.ID,
|
||||
from,
|
||||
"",
|
||||
webhook_module.HookEventSchedule,
|
||||
); err != nil {
|
||||
log.Error("CancelPreviousJobs: %v", err)
|
||||
}
|
||||
|
||||
err2 = gitrepo.SetDefaultBranch(ctx, repo, to)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
notify_service.DeleteRef(ctx, doer, repo, git.RefNameFromBranch(from))
|
||||
notify_service.CreateRef(ctx, doer, repo, git.RefNameFromBranch(to), fromBranch.CommitID)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// UpdateBranch moves a branch reference to the provided commit. permission check should be done before calling this function.
|
||||
func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, branchName, newCommitID, expectedOldCommitID string, force bool) error {
|
||||
branch, err := git_model.GetBranch(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch.IsDeleted {
|
||||
return git_model.ErrBranchNotExist{
|
||||
BranchName: branchName,
|
||||
}
|
||||
}
|
||||
|
||||
if expectedOldCommitID != "" {
|
||||
expectedID, err := gitRepo.ConvertToGitID(expectedOldCommitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ConvertToGitID(old): %w", err)
|
||||
}
|
||||
if expectedID.String() != branch.CommitID {
|
||||
return util.NewInvalidArgumentErrorf("branch commit does not match [expected: %s, given: %s]", expectedID.String(), branch.CommitID)
|
||||
}
|
||||
}
|
||||
|
||||
newID, err := gitRepo.ConvertToGitID(newCommitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ConvertToGitID(new): %w", err)
|
||||
}
|
||||
newCommit, err := gitRepo.GetCommit(newID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if newCommit.ID.String() == branch.CommitID {
|
||||
return nil
|
||||
}
|
||||
|
||||
isForcePush, err := newCommit.IsForcePush(branch.CommitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isForcePush && !force {
|
||||
return util.NewInvalidArgumentErrorf("Force push %s need a confirm force parameter", branchName)
|
||||
}
|
||||
|
||||
pushOpts := git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
Force: isForcePush || force,
|
||||
}
|
||||
|
||||
if expectedOldCommitID != "" {
|
||||
pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, branch.CommitID)
|
||||
}
|
||||
|
||||
// branch protection will be checked in the pre received hook, so that we don't need any check here
|
||||
return gitrepo.Push(ctx, repo, repo, pushOpts)
|
||||
}
|
||||
|
||||
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default or pull request target")
|
||||
|
||||
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
|
||||
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
if branchName == repo.DefaultBranch || branchName == unitPRConfig.DefaultTargetBranch {
|
||||
return ErrBranchIsDefault
|
||||
}
|
||||
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !perm.CanWrite(unit.TypeCode) {
|
||||
return util.NewPermissionDeniedErrorf("permission denied to access repo %d unit %s", repo.ID, unit.TypeCode.LogString())
|
||||
}
|
||||
|
||||
isProtected, err := git_model.IsBranchProtected(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isProtected {
|
||||
return git_model.ErrBranchIsProtected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteBranchInternal(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branchName string, branchCommit *git.Commit) (branchExisted bool, err error) {
|
||||
activeInDB, err := git_model.IsBranchExist(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("IsBranchExist: %w", err)
|
||||
}
|
||||
|
||||
// process the branch in db
|
||||
if activeInDB {
|
||||
if err := git_model.MarkBranchAsDeleted(ctx, repo.ID, branchName, doer.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// process the branch in git
|
||||
if branchCommit != nil {
|
||||
err := gitrepo.DeleteBranch(ctx, repo, branchName, true)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("DeleteBranch: %w", err)
|
||||
}
|
||||
// since the branch existed in git, return branchExisted=true
|
||||
branchExisted = true
|
||||
} else {
|
||||
// the branch didn't exist in git, return activeInDB to indicate whether the branch was active in DB,
|
||||
// for consistency with that the user had seen on the web ui or in the branch list API response.
|
||||
branchExisted = activeInDB
|
||||
}
|
||||
|
||||
return branchExisted, nil
|
||||
}
|
||||
|
||||
// DeleteBranch delete branch
|
||||
func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string) error {
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := CanDeleteBranch(ctx, repo, branchName, doer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branchCommit, err := gitRepo.GetBranchCommit(branchName)
|
||||
// branchCommit can be nil if the branch doesn't exist in git
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
branchExisted, err := db.WithTx2(ctx, func(ctx context.Context) (bool, error) {
|
||||
return deleteBranchInternal(ctx, doer, repo, branchName, branchCommit)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !branchExisted {
|
||||
return git.ErrBranchNotExist{Name: branchName}
|
||||
}
|
||||
|
||||
// Don't return error below this since the deletion has succeeded
|
||||
if branchCommit != nil {
|
||||
deleteBranchSuccessPostProcess(doer, repo, branchName, branchCommit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteBranchSuccessPostProcess(doer *user_model.User, repo *repo_model.Repository, branchName string, branchCommit *git.Commit) {
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
if err := PushUpdate(
|
||||
&repo_module.PushUpdateOptions{
|
||||
RefFullName: git.RefNameFromBranch(branchName),
|
||||
OldCommitID: branchCommit.ID.String(),
|
||||
NewCommitID: objectFormat.EmptyObjectID().String(),
|
||||
PusherID: doer.ID,
|
||||
PusherName: doer.Name,
|
||||
RepoUserName: repo.OwnerName,
|
||||
RepoName: repo.Name,
|
||||
}); err != nil {
|
||||
log.Error("PushUpdateOptions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type BranchSyncOptions struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// branchSyncQueue represents a queue to handle branch sync jobs.
|
||||
var branchSyncQueue *queue.WorkerPoolQueue[*BranchSyncOptions]
|
||||
|
||||
func handlerBranchSync(items ...*BranchSyncOptions) []*BranchSyncOptions {
|
||||
for _, opts := range items {
|
||||
_, err := repo_module.SyncRepoBranches(graceful.GetManager().ShutdownContext(), opts.RepoID, 0)
|
||||
if err != nil {
|
||||
log.Error("syncRepoBranches [%d] failed: %v", opts.RepoID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRepoToBranchSyncQueue(repoID int64) error {
|
||||
return branchSyncQueue.Push(&BranchSyncOptions{
|
||||
RepoID: repoID,
|
||||
})
|
||||
}
|
||||
|
||||
func initBranchSyncQueue(ctx context.Context) error {
|
||||
branchSyncQueue = queue.CreateUniqueQueue(ctx, "branch_sync", handlerBranchSync)
|
||||
if branchSyncQueue == nil {
|
||||
return errors.New("unable to create branch_sync queue")
|
||||
}
|
||||
go graceful.GetManager().RunWithCancel(branchSyncQueue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddAllRepoBranchesToSyncQueue(ctx context.Context) error {
|
||||
if err := db.Iterate(ctx, builder.Eq{"is_empty": false}, func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
return addRepoToBranchSyncQueue(repo.ID)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("run sync all branches failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, newBranchName string) error {
|
||||
if repo.DefaultBranch == newBranchName {
|
||||
return nil
|
||||
}
|
||||
|
||||
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, newBranchName); !exist {
|
||||
return git_model.ErrBranchNotExist{
|
||||
BranchName: newBranchName,
|
||||
}
|
||||
}
|
||||
|
||||
oldDefaultBranchName := repo.DefaultBranch
|
||||
repo.DefaultBranch = newBranchName
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := repo_model.UpdateDefaultBranch(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := actions_model.DeleteScheduleTaskByRepo(ctx, repo.ID); err != nil {
|
||||
log.Error("DeleteCronTaskByRepo: %v", err)
|
||||
}
|
||||
// cancel running cron jobs of this repository and delete old schedules
|
||||
if err := actions_service.CancelPreviousJobs(
|
||||
ctx,
|
||||
repo.ID,
|
||||
oldDefaultBranchName,
|
||||
"",
|
||||
webhook_module.HookEventSchedule,
|
||||
); err != nil {
|
||||
log.Error("CancelPreviousJobs: %v", err)
|
||||
}
|
||||
|
||||
return gitrepo.SetDefaultBranch(ctx, repo, newBranchName)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !repo.IsEmpty {
|
||||
if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{
|
||||
RepoID: repo.ID,
|
||||
}); err != nil {
|
||||
log.Error("AddRepoToLicenseUpdaterQueue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clear divergence cache
|
||||
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
||||
log.Error("DelRepoDivergenceFromCache: %v", err)
|
||||
}
|
||||
|
||||
notify_service.ChangeDefaultBranch(ctx, repo)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BranchDivergingInfo contains the information about the divergence of a head branch to the base branch.
|
||||
type BranchDivergingInfo struct {
|
||||
// whether the base branch contains new commits which are not in the head branch
|
||||
BaseHasNewCommits bool
|
||||
|
||||
// behind/after are number of commits that the head branch is behind/after the base branch, it's 0 if it's unable to calculate.
|
||||
// there could be a case that BaseHasNewCommits=true while the behind/after are both 0 (unable to calculate).
|
||||
HeadCommitsBehind int
|
||||
HeadCommitsAhead int
|
||||
}
|
||||
|
||||
// GetBranchDivergingInfo returns the information about the divergence of a patch branch to the base branch.
|
||||
func GetBranchDivergingInfo(ctx reqctx.RequestContext, baseRepo *repo_model.Repository, baseBranch string, headRepo *repo_model.Repository, headBranch string) (*BranchDivergingInfo, error) {
|
||||
headGitBranch, err := git_model.GetBranch(ctx, headRepo.ID, headBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if headGitBranch.IsDeleted {
|
||||
return nil, git_model.ErrBranchNotExist{
|
||||
BranchName: headBranch,
|
||||
}
|
||||
}
|
||||
baseGitBranch, err := git_model.GetBranch(ctx, baseRepo.ID, baseBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if baseGitBranch.IsDeleted {
|
||||
return nil, git_model.ErrBranchNotExist{
|
||||
BranchName: baseBranch,
|
||||
}
|
||||
}
|
||||
|
||||
info := &BranchDivergingInfo{}
|
||||
if headGitBranch.CommitID == baseGitBranch.CommitID {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// if the fork repo has new commits, this call will fail because they are not in the base repo
|
||||
// exit status 128 - fatal: Invalid symmetric difference expression aaaaaaaaaaaa...bbbbbbbbbbbb
|
||||
// so at the moment, we first check the update time, then check whether the fork branch has base's head
|
||||
diff, err := gitrepo.GetDivergingCommits(ctx, baseRepo, baseGitBranch.CommitID, headGitBranch.CommitID)
|
||||
if err != nil {
|
||||
info.BaseHasNewCommits = baseGitBranch.UpdatedUnix > headGitBranch.UpdatedUnix
|
||||
if headRepo.IsFork && info.BaseHasNewCommits {
|
||||
return info, nil
|
||||
}
|
||||
// if the base's update time is before the fork, check whether the base's head is in the fork
|
||||
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, headRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headCommit, err := headGitRepo.GetCommit(headGitBranch.CommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseCommitID, err := git.NewIDFromString(baseGitBranch.CommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPreviousCommit, _ := headCommit.HasPreviousCommit(baseCommitID)
|
||||
info.BaseHasNewCommits = !hasPreviousCommit
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.HeadCommitsBehind, info.HeadCommitsAhead = diff.Behind, diff.Ahead
|
||||
info.BaseHasNewCommits = info.HeadCommitsBehind > 0
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func DeleteBranchAfterMerge(ctx context.Context, doer *user_model.User, prID int64, outFullBranchName *string) error {
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, prID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = pr.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = pr.LoadBaseRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if pr.HeadRepo == nil {
|
||||
// Forked repository has already been deleted
|
||||
return util.ErrorWrapTranslatable(util.ErrNotExist, "repo.branch.deletion_failed", "(deleted-repo):"+pr.HeadBranch)
|
||||
}
|
||||
if err = pr.HeadRepo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch
|
||||
if outFullBranchName != nil {
|
||||
*outFullBranchName = fullBranchName
|
||||
}
|
||||
|
||||
errFailedToDelete := func(err error) error {
|
||||
return util.ErrorWrapTranslatable(err, "repo.branch.deletion_failed", fullBranchName)
|
||||
}
|
||||
|
||||
// Don't clean up unmerged and unclosed PRs and agit PRs
|
||||
if !pr.HasMerged && !pr.Issue.IsClosed && pr.Flow != issues_model.PullRequestFlowGithub {
|
||||
return errFailedToDelete(util.ErrUnprocessableContent)
|
||||
}
|
||||
|
||||
// Don't clean up when there are other PR's that use this branch as head branch.
|
||||
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
return errFailedToDelete(util.ErrUnprocessableContent)
|
||||
}
|
||||
|
||||
if err := CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, doer); err != nil {
|
||||
if errors.Is(err, util.ErrPermissionDenied) {
|
||||
return errFailedToDelete(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
gitBaseRepo, gitBaseCloser, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gitBaseCloser.Close()
|
||||
|
||||
gitHeadRepo, gitHeadCloser, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gitHeadCloser.Close()
|
||||
|
||||
// Check if branch has no new commits
|
||||
headCommitID, err := gitBaseRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
log.Error("GetRefCommitID: %v", err)
|
||||
return errFailedToDelete(err)
|
||||
}
|
||||
branchCommitID, err := gitHeadRepo.GetBranchCommitID(pr.HeadBranch)
|
||||
if err != nil {
|
||||
log.Error("GetBranchCommitID: %v", err)
|
||||
return errFailedToDelete(err)
|
||||
}
|
||||
if headCommitID != branchCommitID {
|
||||
return util.ErrorWrapTranslatable(util.ErrUnprocessableContent, "repo.branch.delete_branch_has_new_commits", fullBranchName)
|
||||
}
|
||||
|
||||
err = DeleteBranch(ctx, doer, pr.HeadRepo, gitHeadRepo, pr.HeadBranch)
|
||||
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
|
||||
return errFailedToDelete(err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// intentionally ignore the following error, since the branch has already been deleted successfully
|
||||
if err := issues_model.AddDeletePRBranchComment(ctx, doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil {
|
||||
log.Error("AddDeletePRBranchComment: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
)
|
||||
|
||||
// CacheRef cachhe last commit information of the branch or the tag
|
||||
func CacheRef(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, fullRefName git.RefName) error {
|
||||
commit, err := gitRepo.GetCommit(fullRefName.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gitRepo.LastCommitCache == nil {
|
||||
commitsCount, err := cache.GetInt64(repo.GetCommitsCountCacheKey(fullRefName.ShortName(), true), func() (int64, error) {
|
||||
return gitrepo.CommitsCountOfCommit(ctx, repo, commit.ID.String())
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gitRepo.LastCommitCache = git.NewLastCommitCache(commitsCount, repo.FullName(), gitRepo, cache.GetCache())
|
||||
}
|
||||
|
||||
return commit.CacheCommit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
system_model "gitea.dev/models/system"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GitFsckRepos calls 'git fsck' to check repository health.
|
||||
func GitFsckRepos(ctx context.Context, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
|
||||
log.Trace("Doing: GitFsck")
|
||||
|
||||
if err := db.Iterate(
|
||||
ctx,
|
||||
builder.Expr("id>0 AND is_fsck_enabled=?", true),
|
||||
func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("before fsck of %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
return GitFsckRepo(ctx, repo, timeout, args)
|
||||
},
|
||||
); err != nil {
|
||||
log.Trace("Error: GitFsck: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace("Finished: GitFsck")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitFsckRepo calls 'git fsck' to check an individual repository's health.
|
||||
func GitFsckRepo(ctx context.Context, repo *repo_model.Repository, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
|
||||
log.Trace("Running health check on repository %-v", repo.FullName())
|
||||
if err := gitrepo.Fsck(ctx, repo, timeout, args); err != nil {
|
||||
log.Warn("Failed to health check repository (%-v): %v", repo.FullName(), err)
|
||||
if err = system_model.CreateRepositoryNotice("Failed to health check repository (%s): %v", repo.FullName(), err); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitGcRepos calls 'git gc' to remove unnecessary files and optimize the local repository
|
||||
func GitGcRepos(ctx context.Context, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
|
||||
log.Trace("Doing: GitGcRepos")
|
||||
|
||||
if err := db.Iterate(
|
||||
ctx,
|
||||
builder.Gt{"id": 0},
|
||||
func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("before GC of %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
// we can ignore the error here because it will be logged in GitGCRepo
|
||||
_ = GitGcRepo(ctx, repo, timeout, args)
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace("Finished: GitGcRepos")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitGcRepo calls 'git gc' to remove unnecessary files and optimize the local repository
|
||||
func GitGcRepo(ctx context.Context, repo *repo_model.Repository, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
|
||||
log.Trace("Running git gc on %-v", repo)
|
||||
command := gitcmd.NewCommand("gc").AddArguments(args...)
|
||||
var stdout string
|
||||
var err error
|
||||
stdout, _, err = gitrepo.RunCmdString(ctx, repo, command)
|
||||
if err != nil {
|
||||
log.Error("Repository garbage collection failed for %-v. Stdout: %s\nError: %v", repo, stdout, err)
|
||||
desc := fmt.Sprintf("Repository garbage collection failed (%s). Stdout: %s\nError: %v", repo.FullName(), stdout, err)
|
||||
if err := system_model.CreateRepositoryNotice(desc); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
return fmt.Errorf("Repository garbage collection failed in repo: %s: Error: %w", repo.RelativePath(), err)
|
||||
}
|
||||
|
||||
// Now update the size of the repository
|
||||
if err := repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Updating size as part of garbage collection failed for %-v. Stdout: %s\nError: %v", repo, stdout, err)
|
||||
desc := fmt.Sprintf("Updating size as part of garbage collection failed (%s). Stdout: %s\nError: %v", repo.FullName(), stdout, err)
|
||||
if err := system_model.CreateRepositoryNotice(desc); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
return fmt.Errorf("Updating size as part of garbage collection failed in repo: %s: Error: %w", repo.RelativePath(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatherMissingRepoRecords(ctx context.Context) (repo_model.RepositoryList, error) {
|
||||
repos := make([]*repo_model.Repository, 0, 10)
|
||||
if err := db.Iterate(
|
||||
ctx,
|
||||
builder.Gt{"id": 0},
|
||||
func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("during gathering missing repo records before checking %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
exist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to check dir for %s. %w", repo.FullName(), err)
|
||||
}
|
||||
if !exist {
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
if strings.HasPrefix(err.Error(), "Aborted gathering missing repo") {
|
||||
return nil, err
|
||||
}
|
||||
if err2 := system_model.CreateRepositoryNotice("gatherMissingRepoRecords: %v", err); err2 != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err2)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// DeleteMissingRepositories deletes all repository records that lost Git files.
|
||||
func DeleteMissingRepositories(ctx context.Context, doer *user_model.User) error {
|
||||
repos, err := gatherMissingRepoRecords(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("during DeleteMissingRepositories before %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
log.Trace("Deleting %d/%d...", repo.OwnerID, repo.ID)
|
||||
if err := DeleteRepositoryDirectly(ctx, repo.ID); err != nil {
|
||||
log.Error("Failed to DeleteRepository %-v: Error: %v", repo, err)
|
||||
if err2 := system_model.CreateRepositoryNotice("Failed to DeleteRepository (%s) [%d]: Error: %v", repo.FullName(), repo.ID, err); err2 != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReinitMissingRepositories reinitializes all repository records that lost Git files.
|
||||
func ReinitMissingRepositories(ctx context.Context) error {
|
||||
repos, err := gatherMissingRepoRecords(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("during ReinitMissingRepositories before %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
log.Trace("Initializing %d/%d...", repo.OwnerID, repo.ID)
|
||||
if err := gitrepo.InitRepository(ctx, repo, repo.ObjectFormatName); err != nil {
|
||||
log.Error("Unable (re)initialize repository %d at %s. Error: %v", repo.ID, repo.RelativePath(), err)
|
||||
if err2 := system_model.CreateRepositoryNotice("InitRepository (%s) [%d]: %v", repo.FullName(), repo.ID, err); err2 != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User, mode perm.AccessMode) error {
|
||||
// only allow valid access modes, read, write and admin
|
||||
if mode < perm.AccessModeRead || mode > perm.AccessModeAdmin {
|
||||
return perm.ErrInvalidAccessMode
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, u, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, repo.Owner, u.ID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
collaboration, has, err := db.Get[repo_model.Collaboration](ctx, builder.Eq{
|
||||
"repo_id": repo.ID,
|
||||
"user_id": u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
if collaboration.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
if _, err = db.GetEngine(ctx).
|
||||
Where("repo_id=?", repo.ID).
|
||||
And("user_id=?", u.ID).
|
||||
Cols("mode").
|
||||
Update(&repo_model.Collaboration{
|
||||
Mode: mode,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err = db.Insert(ctx, &repo_model.Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: u.ID,
|
||||
Mode: mode,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return access_model.RecalculateUserAccess(ctx, repo, u.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCollaboration removes collaboration relation between the user and repository.
|
||||
func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) {
|
||||
collaboration := &repo_model.Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: collaborator.ID,
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if has, err := db.GetEngine(ctx).Delete(collaboration); err != nil {
|
||||
return err
|
||||
} else if has == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, collaborator, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = ReconsiderWatches(ctx, repo, collaborator); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unassign a user from any issue (s)he has been assigned to in the repository
|
||||
return ReconsiderRepoIssuesAssignee(ctx, repo, collaborator)
|
||||
})
|
||||
}
|
||||
|
||||
func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
|
||||
if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": user.ID}).
|
||||
In("issue_id", builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repo.ID})).
|
||||
Delete(&issues_model.IssueAssignees{}); err != nil {
|
||||
return fmt.Errorf("Could not delete assignee[%d] %w", user.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
|
||||
if has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo); err != nil || has {
|
||||
return err
|
||||
}
|
||||
if err := repo_model.WatchRepo(ctx, user, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all stopwatches a user has running in the repository
|
||||
if err := issues_model.RemoveStopwatchesByRepoID(ctx, user.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repository
|
||||
return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_AddCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(repoID, userID int64) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||
assert.NoError(t, AddOrUpdateCollaborator(t.Context(), repo, user, perm.AccessModeWrite))
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
|
||||
}
|
||||
testSuccess(1, 4)
|
||||
testSuccess(1, 4)
|
||||
testSuccess(3, 4)
|
||||
}
|
||||
|
||||
func TestRepository_DeleteCollaboration(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22})
|
||||
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
assert.NoError(t, DeleteCollaboration(t.Context(), repo, user))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID})
|
||||
|
||||
assert.NoError(t, DeleteCollaboration(t.Context(), repo, user))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID})
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
|
||||
}
|
||||
|
||||
func TestRepository_DeleteCollaborationRemovesSubscriptionsAndStopwatches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx := t.Context()
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22})
|
||||
assert.NoError(t, repo.LoadOwner(ctx))
|
||||
assert.NoError(t, repo_model.WatchRepo(ctx, user, repo, true))
|
||||
|
||||
hasAccess, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, hasAccess)
|
||||
|
||||
issueCount, err := db.GetEngine(ctx).Where("repo_id=?", repo.ID).Count(new(issues_model.Issue))
|
||||
assert.NoError(t, err)
|
||||
tempIssue := &issues_model.Issue{
|
||||
RepoID: repo.ID,
|
||||
Index: issueCount + 1,
|
||||
PosterID: repo.OwnerID,
|
||||
Title: "temp issue",
|
||||
Content: "temp",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, tempIssue))
|
||||
assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, tempIssue.ID, true))
|
||||
ok, err := issues_model.CreateIssueStopwatch(ctx, user, tempIssue)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
assert.NoError(t, DeleteCollaboration(ctx, repo, user))
|
||||
|
||||
hasAccess, err = access_model.HasAnyUnitAccess(ctx, user.ID, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
|
||||
watch, err := repo_model.GetWatch(ctx, user.ID, repo.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, repo_model.IsWatchMode(watch.Mode))
|
||||
|
||||
_, exists, err := issues_model.GetIssueWatch(ctx, user.ID, tempIssue.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
hasStopwatch, _, _, err := issues_model.HasUserStopwatch(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasStopwatch)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
gitea_ctx "gitea.dev/services/context"
|
||||
)
|
||||
|
||||
type ContainedLinks struct { // TODO: better name?
|
||||
Branches []*namedLink `json:"branches"`
|
||||
Tags []*namedLink `json:"tags"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
type namedLink struct { // TODO: better name?
|
||||
Name string `json:"name"`
|
||||
WebLink string `json:"web_link"`
|
||||
}
|
||||
|
||||
// LoadBranchesAndTags creates a new repository branch
|
||||
func LoadBranchesAndTags(ctx context.Context, baseRepo *gitea_ctx.Repository, commitSHA string) (*ContainedLinks, error) {
|
||||
containedTags, err := baseRepo.GitRepo.ListOccurrences(ctx, "tag", commitSHA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encountered a problem while querying %s: %w", "tags", err)
|
||||
}
|
||||
containedBranches, err := baseRepo.GitRepo.ListOccurrences(ctx, "branch", commitSHA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encountered a problem while querying %s: %w", "branches", err)
|
||||
}
|
||||
|
||||
result := &ContainedLinks{
|
||||
DefaultBranch: baseRepo.Repository.DefaultBranch,
|
||||
Branches: make([]*namedLink, 0, len(containedBranches)),
|
||||
Tags: make([]*namedLink, 0, len(containedTags)),
|
||||
}
|
||||
for _, tag := range containedTags {
|
||||
// TODO: Use a common method to get the link to a branch/tag instead of hard-coding it here
|
||||
result.Tags = append(result.Tags, &namedLink{
|
||||
Name: tag,
|
||||
WebLink: fmt.Sprintf("%s/src/tag/%s", baseRepo.RepoLink, util.PathEscapeSegments(tag)),
|
||||
})
|
||||
}
|
||||
for _, branch := range containedBranches {
|
||||
result.Branches = append(result.Branches, &namedLink{
|
||||
Name: branch,
|
||||
WebLink: fmt.Sprintf("%s/src/branch/%s", baseRepo.RepoLink, util.PathEscapeSegments(branch)),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package commitstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
func getCacheKey(repoID int64, brancheName string) string {
|
||||
hashBytes := sha256.Sum256(fmt.Appendf(nil, "%d:%s", repoID, brancheName))
|
||||
return fmt.Sprintf("commit_status:%x", hashBytes)
|
||||
}
|
||||
|
||||
type commitStatusCacheValue struct {
|
||||
State string `json:"state"`
|
||||
TargetURL string `json:"target_url"`
|
||||
}
|
||||
|
||||
func getCommitStatusCache(repoID int64, branchName string) *commitStatusCacheValue {
|
||||
c := cache.GetCache()
|
||||
statusStr, ok := c.Get(getCacheKey(repoID, branchName))
|
||||
if ok && statusStr != "" {
|
||||
var cv commitStatusCacheValue
|
||||
err := json.Unmarshal([]byte(statusStr), &cv)
|
||||
if err == nil {
|
||||
return &cv
|
||||
}
|
||||
log.Warn("getCommitStatusCache: json.Unmarshal failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateCommitStatusCache(repoID int64, branchName string, state commitstatus.CommitStatusState, targetURL string) error {
|
||||
c := cache.GetCache()
|
||||
bs, err := json.Marshal(commitStatusCacheValue{
|
||||
State: state.String(),
|
||||
TargetURL: targetURL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("updateCommitStatusCache: json.Marshal failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
return c.Put(getCacheKey(repoID, branchName), string(bs), 3*24*60)
|
||||
}
|
||||
|
||||
func deleteCommitStatusCache(repoID int64, branchName string) error {
|
||||
c := cache.GetCache()
|
||||
return c.Delete(getCacheKey(repoID, branchName))
|
||||
}
|
||||
|
||||
// CreateCommitStatus creates a new CommitStatus given a bunch of parameters
|
||||
// NOTE: All text-values will be trimmed from whitespaces.
|
||||
// Requires: Repo, Creator, SHA
|
||||
func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, creator *user_model.User, sha string, status *git_model.CommitStatus) error {
|
||||
// confirm that commit is exist
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository[%s]: %w", repo.RelativePath(), err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
|
||||
commit, err := gitRepo.GetCommit(sha)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetCommit[%s]: %w", sha, err)
|
||||
}
|
||||
if len(sha) != objectFormat.FullLength() {
|
||||
// use complete commit sha
|
||||
sha = commit.ID.String()
|
||||
}
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{
|
||||
Repo: repo,
|
||||
Creator: creator,
|
||||
SHA: commit.ID,
|
||||
CommitStatus: status,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err)
|
||||
}
|
||||
|
||||
return git_model.UpdateCommitStatusSummary(ctx, repo.ID, commit.ID.String())
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notify.CreateCommitStatus(ctx, repo, repo_module.CommitToPushCommit(commit), creator, status)
|
||||
|
||||
defaultBranchCommit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetBranchCommit[%s]: %w", repo.DefaultBranch, err)
|
||||
}
|
||||
|
||||
if commit.ID.String() == defaultBranchCommit.ID.String() { // since one commit status updated, the combined commit status should be invalid
|
||||
if err := deleteCommitStatusCache(repo.ID, repo.DefaultBranch); err != nil {
|
||||
log.Error("deleteCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindReposLatestCommitStatuses loading repository default branch latest combined commit status with cache
|
||||
func FindReposLatestCommitStatuses(ctx context.Context, repos []*repo_model.Repository) ([]*git_model.CommitStatus, error) {
|
||||
results := make([]*git_model.CommitStatus, len(repos))
|
||||
allCached := true
|
||||
for i, repo := range repos {
|
||||
if cv := getCommitStatusCache(repo.ID, repo.DefaultBranch); cv != nil {
|
||||
results[i] = &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusState(cv.State),
|
||||
TargetURL: cv.TargetURL,
|
||||
}
|
||||
} else {
|
||||
allCached = false
|
||||
}
|
||||
}
|
||||
|
||||
if allCached {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// collect the latest commit of each repo
|
||||
// at most there are dozens of repos (limited by MaxResponseItems), so it's not a big problem at the moment
|
||||
repoBranchNames := make(map[int64]string, len(repos))
|
||||
for i, repo := range repos {
|
||||
if results[i] == nil {
|
||||
repoBranchNames[repo.ID] = repo.DefaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
repoIDsToLatestCommitSHAs, err := git_model.FindBranchesByRepoAndBranchName(ctx, repoBranchNames)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindBranchesByRepoAndBranchName: %v", err)
|
||||
}
|
||||
|
||||
var repoSHAs []git_model.RepoSHA
|
||||
for id, sha := range repoIDsToLatestCommitSHAs {
|
||||
repoSHAs = append(repoSHAs, git_model.RepoSHA{RepoID: id, SHA: sha})
|
||||
}
|
||||
|
||||
summaryResults, err := git_model.GetLatestCommitStatusForRepoAndSHAs(ctx, repoSHAs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetLatestCommitStatusForRepoAndSHAs: %v", err)
|
||||
}
|
||||
|
||||
for _, summary := range summaryResults {
|
||||
for i, repo := range repos {
|
||||
if repo.ID == summary.RepoID {
|
||||
results[i] = summary
|
||||
repoSHAs = slices.DeleteFunc(repoSHAs, func(repoSHA git_model.RepoSHA) bool {
|
||||
return repoSHA.RepoID == repo.ID
|
||||
})
|
||||
if results[i] != nil {
|
||||
if err := updateCommitStatusCache(repo.ID, repo.DefaultBranch, results[i].State, results[i].TargetURL); err != nil {
|
||||
log.Error("updateCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(repoSHAs) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// call the database O(1) times to get the commit statuses for all repos
|
||||
repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoSHAs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetLatestCommitStatusForPairs: %v", err)
|
||||
}
|
||||
|
||||
for i, repo := range repos {
|
||||
if results[i] == nil {
|
||||
results[i] = git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID])
|
||||
if results[i] != nil {
|
||||
if err := updateCommitStatusCache(repo.ID, repo.DefaultBranch, results[i].State, results[i].TargetURL); err != nil {
|
||||
log.Error("updateCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/avatars"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
api "gitea.dev/modules/structs"
|
||||
)
|
||||
|
||||
const (
|
||||
contributorStatsCacheKey = "GetContributorStats/%s/%s"
|
||||
contributorStatsCacheTimeout int64 = 60 * 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAwaitGeneration = errors.New("generation took longer than ")
|
||||
awaitGenerationTime = time.Second * 5
|
||||
generateLock = sync.Map{}
|
||||
)
|
||||
|
||||
type WeekData struct {
|
||||
Week int64 `json:"week"` // Starting day of the week as Unix timestamp
|
||||
Additions int `json:"additions"` // Number of additions in that week
|
||||
Deletions int `json:"deletions"` // Number of deletions in that week
|
||||
Commits int `json:"commits"` // Number of commits in that week
|
||||
}
|
||||
|
||||
// ContributorData represents statistical git commit count data
|
||||
type ContributorData struct {
|
||||
Name string `json:"name"` // Display name of the contributor
|
||||
Login string `json:"login"` // Login name of the contributor in case it exists
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
HomeLink string `json:"home_link"`
|
||||
TotalCommits int64 `json:"total_commits"`
|
||||
Weeks map[int64]*WeekData `json:"weeks"`
|
||||
}
|
||||
|
||||
// ExtendedCommitStats contains information for commit stats with author data
|
||||
type ExtendedCommitStats struct {
|
||||
Author *api.CommitUser `json:"author"`
|
||||
Stats *api.CommitStats `json:"stats"`
|
||||
}
|
||||
|
||||
const layout = time.DateOnly
|
||||
|
||||
func findLastSundayBeforeDate(dateStr string) (string, error) {
|
||||
date, err := time.Parse(layout, dateStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
weekday := date.Weekday()
|
||||
daysToSubtract := int(weekday) - int(time.Sunday)
|
||||
if daysToSubtract < 0 {
|
||||
daysToSubtract += 7
|
||||
}
|
||||
|
||||
lastSunday := date.AddDate(0, 0, -daysToSubtract)
|
||||
return lastSunday.Format(layout), nil
|
||||
}
|
||||
|
||||
// GetContributorStats returns contributors stats for git commits for given revision or default branch
|
||||
func GetContributorStats(ctx context.Context, cache cache.StringCache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
|
||||
// as GetContributorStats is resource intensive we cache the result
|
||||
cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
|
||||
if !cache.IsExist(cacheKey) {
|
||||
genReady := make(chan struct{})
|
||||
|
||||
// don't start multiple async generations
|
||||
_, run := generateLock.Load(cacheKey)
|
||||
if run {
|
||||
return nil, ErrAwaitGeneration
|
||||
}
|
||||
|
||||
generateLock.Store(cacheKey, struct{}{})
|
||||
// run generation async
|
||||
go generateContributorStats(genReady, cache, cacheKey, repo, revision)
|
||||
|
||||
select {
|
||||
case <-time.After(awaitGenerationTime):
|
||||
return nil, ErrAwaitGeneration
|
||||
case <-genReady:
|
||||
// we got generation ready before timeout
|
||||
break
|
||||
}
|
||||
}
|
||||
// TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout)
|
||||
var res map[string]*ContributorData
|
||||
if _, cacheErr := cache.GetJSON(cacheKey, &res); cacheErr != nil {
|
||||
return nil, fmt.Errorf("cached error: %w", cacheErr.ToError())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
|
||||
func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
|
||||
baseCommit, err := repo.GetCommit(revision)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitCmd := gitcmd.NewCommand("log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
|
||||
// AddOptionFormat("--max-count=%d", limit)
|
||||
gitCmd.AddDynamicArguments(baseCommit.ID.String())
|
||||
|
||||
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
|
||||
var extendedCommitStats []*ExtendedCommitStats
|
||||
err = gitCmd.WithDir(repo.Path).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
scanner := bufio.NewScanner(stdoutReader)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "---" {
|
||||
continue
|
||||
}
|
||||
scanner.Scan()
|
||||
authorName := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
authorEmail := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
date := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
stats := strings.TrimSpace(scanner.Text())
|
||||
if authorName == "" || authorEmail == "" || date == "" || stats == "" {
|
||||
// FIXME: find a better way to parse the output so that we will handle this properly
|
||||
log.Warn("Something is wrong with git log output, skipping...")
|
||||
log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats)
|
||||
continue
|
||||
}
|
||||
// 1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
fields := strings.Split(stats, ",")
|
||||
|
||||
commitStats := api.CommitStats{}
|
||||
for _, field := range fields[1:] {
|
||||
parts := strings.Split(strings.TrimSpace(field), " ")
|
||||
value, contributionType := parts[0], parts[1]
|
||||
amount, _ := strconv.Atoi(value)
|
||||
|
||||
if strings.HasPrefix(contributionType, "insertion") {
|
||||
commitStats.Additions = amount
|
||||
} else {
|
||||
commitStats.Deletions = amount
|
||||
}
|
||||
}
|
||||
commitStats.Total = commitStats.Additions + commitStats.Deletions
|
||||
scanner.Text() // empty line at the end
|
||||
|
||||
res := &ExtendedCommitStats{
|
||||
Author: &api.CommitUser{
|
||||
Identity: api.Identity{
|
||||
Name: authorName,
|
||||
Email: authorEmail,
|
||||
},
|
||||
Date: date,
|
||||
},
|
||||
Stats: &commitStats,
|
||||
}
|
||||
extendedCommitStats = append(extendedCommitStats, res)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
RunWithStderr(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ContributorsCommitStats: %w", err)
|
||||
}
|
||||
|
||||
return extendedCommitStats, nil
|
||||
}
|
||||
|
||||
func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) {
|
||||
ctx := graceful.GetManager().HammerContext()
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
_ = cache.PutJSON(cacheKey, fmt.Errorf("OpenRepository: %w", err), contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if len(revision) == 0 {
|
||||
revision = repo.DefaultBranch
|
||||
}
|
||||
extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
|
||||
if err != nil {
|
||||
_ = cache.PutJSON(cacheKey, fmt.Errorf("ExtendedCommitStats: %w", err), contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
if len(extendedCommitStats) == 0 {
|
||||
_ = cache.PutJSON(cacheKey, fmt.Errorf("no commit stats returned for revision '%s'", revision), contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
layout := time.DateOnly
|
||||
|
||||
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
|
||||
contributorsCommitStats := make(map[string]*ContributorData)
|
||||
contributorsCommitStats["total"] = &ContributorData{
|
||||
Name: "Total",
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
total := contributorsCommitStats["total"]
|
||||
|
||||
for _, v := range extendedCommitStats {
|
||||
userEmail := v.Author.Email
|
||||
if len(userEmail) == 0 {
|
||||
continue
|
||||
}
|
||||
u, _ := user_model.GetUserByEmail(ctx, userEmail)
|
||||
if u != nil {
|
||||
// update userEmail with user's primary email address so
|
||||
// that different mail addresses will linked to same account
|
||||
userEmail = u.GetEmail()
|
||||
}
|
||||
// duplicated logic
|
||||
if _, ok := contributorsCommitStats[userEmail]; !ok {
|
||||
if u == nil {
|
||||
avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
|
||||
if avatarLink == "" {
|
||||
avatarLink = unknownUserAvatarLink
|
||||
}
|
||||
contributorsCommitStats[userEmail] = &ContributorData{
|
||||
Name: v.Author.Name,
|
||||
AvatarLink: avatarLink,
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
} else {
|
||||
contributorsCommitStats[userEmail] = &ContributorData{
|
||||
Name: u.DisplayName(),
|
||||
Login: u.LowerName,
|
||||
AvatarLink: u.AvatarLinkWithSize(ctx, 0),
|
||||
HomeLink: u.HomeLink(),
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update user statistics
|
||||
user := contributorsCommitStats[userEmail]
|
||||
startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
|
||||
|
||||
val, _ := time.Parse(layout, startingOfWeek)
|
||||
week := val.UnixMilli()
|
||||
|
||||
if user.Weeks[week] == nil {
|
||||
user.Weeks[week] = &WeekData{
|
||||
Additions: 0,
|
||||
Deletions: 0,
|
||||
Commits: 0,
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
if total.Weeks[week] == nil {
|
||||
total.Weeks[week] = &WeekData{
|
||||
Additions: 0,
|
||||
Deletions: 0,
|
||||
Commits: 0,
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
user.Weeks[week].Additions += v.Stats.Additions
|
||||
user.Weeks[week].Deletions += v.Stats.Deletions
|
||||
user.Weeks[week].Commits++
|
||||
user.TotalCommits++
|
||||
|
||||
// Update overall statistics
|
||||
total.Weeks[week].Additions += v.Stats.Additions
|
||||
total.Weeks[week].Deletions += v.Stats.Deletions
|
||||
total.Weeks[week].Commits++
|
||||
total.TotalCommits++
|
||||
}
|
||||
|
||||
_ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
|
||||
generateLock.Delete(cacheKey)
|
||||
if genDone != nil {
|
||||
genDone <- struct{}{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_ContributorsGraph(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
mockCache, err := cache.NewStringCache(setting.Cache{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
generateContributorStats(nil, mockCache, "key", repo, "404ref")
|
||||
var data map[string]*ContributorData
|
||||
_, getErr := mockCache.GetJSON("key", &data)
|
||||
assert.NotNil(t, getErr)
|
||||
assert.ErrorContains(t, getErr.ToError(), "object does not exist")
|
||||
|
||||
generateContributorStats(nil, mockCache, "key2", repo, "master")
|
||||
exist, _ := mockCache.GetJSON("key2", &data)
|
||||
assert.True(t, exist)
|
||||
var keys []string
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
assert.Equal(t, []string{
|
||||
"ethantkoenig@gmail.com",
|
||||
"jimmy.praet@telenet.be",
|
||||
"jon@allspice.io",
|
||||
"total", // generated summary
|
||||
}, keys)
|
||||
|
||||
assert.Equal(t, &ContributorData{
|
||||
Name: "Ethan Koenig",
|
||||
AvatarLink: "/assets/img/avatar_default.png",
|
||||
TotalCommits: 1,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["ethantkoenig@gmail.com"])
|
||||
assert.Equal(t, &ContributorData{
|
||||
Name: "Total",
|
||||
AvatarLink: "",
|
||||
TotalCommits: 3,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800)
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1607817600000: {
|
||||
Week: 1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500)
|
||||
Additions: 10,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1624752000000: {
|
||||
Week: 1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200)
|
||||
Additions: 2,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["total"])
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
system_model "gitea.dev/models/system"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/options"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates/vars"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// CreateRepoOptions contains the create repository options
|
||||
type CreateRepoOptions struct {
|
||||
Name string
|
||||
Description string
|
||||
Website string
|
||||
OriginalURL string
|
||||
GitServiceType api.GitServiceType
|
||||
Gitignores string
|
||||
IssueLabels string
|
||||
License string
|
||||
Readme string
|
||||
DefaultBranch string
|
||||
IsPrivate bool
|
||||
IsMirror bool
|
||||
IsTemplate bool
|
||||
AutoInit bool
|
||||
Status repo_model.RepositoryStatus
|
||||
TrustModel repo_model.TrustModelType
|
||||
MirrorInterval string
|
||||
ObjectFormatName string
|
||||
}
|
||||
|
||||
func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir string, opts CreateRepoOptions) error {
|
||||
commitTimeStr := time.Now().Format(time.RFC3339)
|
||||
authorSig := repo.Owner.NewGitSig()
|
||||
|
||||
// Because this may call hooks we should pass in the environment
|
||||
env := append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+authorSig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+authorSig.Email,
|
||||
"GIT_AUTHOR_DATE="+commitTimeStr,
|
||||
"GIT_COMMITTER_NAME="+authorSig.Name,
|
||||
"GIT_COMMITTER_EMAIL="+authorSig.Email,
|
||||
"GIT_COMMITTER_DATE="+commitTimeStr,
|
||||
)
|
||||
|
||||
// Clone to temporary path and do the init commit.
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, repo, tmpDir, git.CloneRepoOptions{
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
log.Error("Failed to clone from %v into %s\nError: %v", repo, tmpDir, err)
|
||||
return fmt.Errorf("git clone: %w", err)
|
||||
}
|
||||
|
||||
// README
|
||||
data, err := options.Readme(opts.Readme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.Readme, err)
|
||||
}
|
||||
|
||||
cloneLink := repo.CloneLink(ctx, nil /* no doer so do not generate user-related SSH link */)
|
||||
match := map[string]string{
|
||||
"Name": repo.Name,
|
||||
"Description": util.NormalizeStringEOL(repo.Description),
|
||||
"CloneURL.SSH": cloneLink.SSH,
|
||||
"CloneURL.HTTPS": cloneLink.HTTPS,
|
||||
"OwnerName": repo.OwnerName,
|
||||
}
|
||||
res, err := vars.Expand(string(data), match)
|
||||
if err != nil {
|
||||
// here we could just log the error and continue the rendering
|
||||
log.Error("unable to expand template vars for repo README: %s, err: %v", opts.Readme, err)
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(tmpDir, "README.md"),
|
||||
[]byte(res), 0o644); err != nil {
|
||||
return fmt.Errorf("write README.md: %w", err)
|
||||
}
|
||||
|
||||
// .gitignore
|
||||
if len(opts.Gitignores) > 0 {
|
||||
var buf bytes.Buffer
|
||||
names := strings.SplitSeq(opts.Gitignores, ",")
|
||||
for name := range names {
|
||||
data, err = options.Gitignore(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRepoInitFile[%s]: %w", name, err)
|
||||
}
|
||||
buf.WriteString("# ---> " + name + "\n")
|
||||
buf.Write(data)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
if buf.Len() > 0 {
|
||||
if err = os.WriteFile(filepath.Join(tmpDir, ".gitignore"), buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write .gitignore: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LICENSE
|
||||
if len(opts.License) > 0 {
|
||||
data, err = repo_module.GetLicense(opts.License, &repo_module.LicenseValues{
|
||||
Owner: repo.OwnerName,
|
||||
Email: authorSig.Email,
|
||||
Repo: repo.Name,
|
||||
Year: time.Now().Format("2006"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getLicense[%s]: %w", opts.License, err)
|
||||
}
|
||||
|
||||
if err = os.WriteFile(filepath.Join(tmpDir, "LICENSE"), data, 0o644); err != nil {
|
||||
return fmt.Errorf("write LICENSE: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitRepository initializes README and .gitignore if needed.
|
||||
func initRepository(ctx context.Context, u *user_model.User, repo *repo_model.Repository, opts CreateRepoOptions) (err error) {
|
||||
// Init git bare new repository.
|
||||
if err = gitrepo.InitRepository(ctx, repo, repo.ObjectFormatName); err != nil {
|
||||
return fmt.Errorf("git.InitRepository: %w", err)
|
||||
} else if err = gitrepo.CreateDelegateHooks(ctx, repo); err != nil {
|
||||
return fmt.Errorf("createDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
repo.DefaultBranch = util.IfZero(opts.DefaultBranch, setting.Repository.DefaultBranch)
|
||||
repo.DefaultWikiBranch = setting.Repository.DefaultBranch
|
||||
if !opts.AutoInit {
|
||||
repo.IsEmpty = true
|
||||
}
|
||||
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_empty", "default_branch", "default_wiki_branch"); err != nil {
|
||||
return fmt.Errorf("updateRepository: %w", err)
|
||||
}
|
||||
|
||||
// Initialize repository according to user's choice.
|
||||
if opts.AutoInit {
|
||||
tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("repos-" + repo.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp dir for repository %s: %w", repo.FullName(), err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err = prepareRepoCommit(ctx, repo, tmpDir, opts); err != nil {
|
||||
return fmt.Errorf("prepareRepoCommit: %w", err)
|
||||
}
|
||||
|
||||
// Apply changes and commit.
|
||||
if err = initRepoCommit(ctx, tmpDir, repo, u); err != nil {
|
||||
return fmt.Errorf("initRepoCommit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("setDefaultBranch: %w", err)
|
||||
}
|
||||
|
||||
// Re-fetch the repository from database before updating it (keep changes that were done earlier with SQL)
|
||||
if repo, err = repo_model.GetRepositoryByID(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("getRepositoryByID: %w", err)
|
||||
}
|
||||
|
||||
if _, err := repo_module.SyncRepoBranches(ctx, repo.ID, u.ID); err != nil {
|
||||
return fmt.Errorf("SyncRepoBranches: %w", err)
|
||||
}
|
||||
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRepositoryDirectly creates a repository for the user/organization.
|
||||
// if needsUpdateToReady is true, it will update the repository status to ready when success
|
||||
func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User,
|
||||
opts CreateRepoOptions, needsUpdateToReady bool,
|
||||
) (*repo_model.Repository, error) {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return nil, repo_model.ErrReachLimitOfRepo{
|
||||
Limit: owner.MaxRepoCreation,
|
||||
}
|
||||
}
|
||||
|
||||
if len(opts.DefaultBranch) == 0 {
|
||||
opts.DefaultBranch = setting.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
// Check if label template exist
|
||||
if len(opts.IssueLabels) > 0 {
|
||||
if _, err := repo_module.LoadTemplateLabelsByDisplayName(opts.IssueLabels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ObjectFormatName == "" {
|
||||
opts.ObjectFormatName = git.Sha1ObjectFormat.Name()
|
||||
}
|
||||
if opts.ObjectFormatName != git.Sha1ObjectFormat.Name() && opts.ObjectFormatName != git.Sha256ObjectFormat.Name() {
|
||||
return nil, fmt.Errorf("unsupported object format: %s", opts.ObjectFormatName)
|
||||
}
|
||||
|
||||
repo := &repo_model.Repository{
|
||||
OwnerID: owner.ID,
|
||||
Owner: owner,
|
||||
OwnerName: owner.Name,
|
||||
Name: opts.Name,
|
||||
LowerName: strings.ToLower(opts.Name),
|
||||
Description: opts.Description,
|
||||
Website: opts.Website,
|
||||
OriginalURL: opts.OriginalURL,
|
||||
OriginalServiceType: opts.GitServiceType,
|
||||
IsPrivate: opts.IsPrivate,
|
||||
IsFsckEnabled: !opts.IsMirror,
|
||||
IsTemplate: opts.IsTemplate,
|
||||
CloseIssuesViaCommitInAnyBranch: setting.Repository.DefaultCloseIssuesViaCommitsInAnyBranch,
|
||||
Status: opts.Status,
|
||||
IsEmpty: !opts.AutoInit,
|
||||
TrustModel: opts.TrustModel,
|
||||
IsMirror: opts.IsMirror,
|
||||
DefaultBranch: opts.DefaultBranch,
|
||||
DefaultWikiBranch: setting.Repository.DefaultBranch,
|
||||
ObjectFormatName: opts.ObjectFormatName,
|
||||
}
|
||||
|
||||
// 1 - create the repository database operations first
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return createRepositoryInDB(ctx, doer, owner, repo, false)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// last - clean up if something goes wrong
|
||||
// WARNING: Don't override all later err with local variables
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// we can not use `ctx` because it may be canceled or timed out
|
||||
cleanupRepository(repo)
|
||||
}
|
||||
}()
|
||||
|
||||
// No need for init mirror.
|
||||
if opts.IsMirror {
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// 2 - check whether the repository with the same storage exists
|
||||
var isExist bool
|
||||
isExist, err = gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repo.FullName(), err)
|
||||
return nil, err
|
||||
}
|
||||
if isExist {
|
||||
log.Error("Files already exist in %s and we are not going to adopt or delete.", repo.FullName())
|
||||
// Don't return directly, we need err in defer to cleanupRepository
|
||||
err = repo_model.ErrRepoFilesAlreadyExist{
|
||||
Uname: repo.OwnerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3 - init git repository in storage
|
||||
if err = initRepository(ctx, doer, repo, opts); err != nil {
|
||||
return nil, fmt.Errorf("initRepository: %w", err)
|
||||
}
|
||||
|
||||
// 4 - Initialize Issue Labels if selected
|
||||
if len(opts.IssueLabels) > 0 {
|
||||
if err = repo_module.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil {
|
||||
return nil, fmt.Errorf("InitializeLabels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5 - Update the git repository
|
||||
if err = updateGitRepoAfterCreate(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("updateGitRepoAfterCreate: %w", err)
|
||||
}
|
||||
|
||||
// 6 - update licenses
|
||||
var licenses []string
|
||||
if len(opts.License) > 0 {
|
||||
licenses = append(licenses, opts.License)
|
||||
|
||||
var stdout string
|
||||
stdout, _, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("rev-parse", "HEAD"))
|
||||
if err != nil {
|
||||
log.Error("CreateRepository(git rev-parse HEAD) in %v: Stdout: %s\nError: %v", repo, stdout, err)
|
||||
return nil, fmt.Errorf("CreateRepository(git rev-parse HEAD): %w", err)
|
||||
}
|
||||
if err = repo_model.UpdateRepoLicenses(ctx, repo, stdout, licenses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 7 - update repository status to be ready
|
||||
if needsUpdateToReady {
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err = repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "status"); err != nil {
|
||||
return nil, fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// createRepositoryInDB creates a repository for the user/organization.
|
||||
func createRepositoryInDB(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository, isFork bool) (err error) {
|
||||
if err = repo_model.IsUsableRepoName(repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, u, repo.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: u.Name,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.DeleteRedirect(ctx, u.ID, repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert units for repo
|
||||
defaultUnits := unit.DefaultRepoUnits
|
||||
switch {
|
||||
case isFork:
|
||||
defaultUnits = unit.DefaultForkRepoUnits
|
||||
case repo.IsMirror:
|
||||
defaultUnits = unit.DefaultMirrorRepoUnits
|
||||
case repo.IsTemplate:
|
||||
defaultUnits = unit.DefaultTemplateRepoUnits
|
||||
}
|
||||
units := make([]repo_model.RepoUnit, 0, len(defaultUnits))
|
||||
for _, tp := range defaultUnits {
|
||||
switch tp {
|
||||
case unit.TypeIssues:
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.IssuesConfig{
|
||||
EnableTimetracker: setting.Service.DefaultEnableTimetracking,
|
||||
AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime,
|
||||
EnableDependencies: setting.Service.DefaultEnableDependencies,
|
||||
},
|
||||
})
|
||||
case unit.TypePullRequests:
|
||||
units = append(units, repo_model.DefaultPullRequestsUnit(repo.ID))
|
||||
case unit.TypeProjects:
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Config: &repo_model.ProjectsConfig{ProjectsMode: repo_model.ProjectsModeAll},
|
||||
})
|
||||
default:
|
||||
units = append(units, repo_model.RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Insert(ctx, units); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remember visibility preference.
|
||||
u.LastRepoVisibility = repo.IsPrivate
|
||||
if err = user_model.UpdateUserCols(ctx, u, "last_repo_visibility"); err != nil {
|
||||
return fmt.Errorf("UpdateUserCols: %w", err)
|
||||
}
|
||||
|
||||
if err = user_model.IncrUserRepoNum(ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("IncrUserRepoNum: %w", err)
|
||||
}
|
||||
u.NumRepos++
|
||||
|
||||
// Give access to all members in teams with access to all repositories.
|
||||
if u.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, u.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FindOrgTeams: %w", err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin, err := access_model.IsUserRepoAdmin(ctx, repo, doer); err != nil {
|
||||
return fmt.Errorf("IsUserRepoAdmin: %w", err)
|
||||
} else if !isAdmin {
|
||||
// Make creator repo admin if it wasn't assigned automatically
|
||||
if err = AddOrUpdateCollaborator(ctx, repo, doer, perm.AccessModeAdmin); err != nil {
|
||||
return fmt.Errorf("AddCollaborator: %w", err)
|
||||
}
|
||||
}
|
||||
} else if err = access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
// Organization automatically called this in AddRepository method.
|
||||
return fmt.Errorf("RecalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
if err = repo_model.WatchRepo(ctx, doer, repo, true); err != nil {
|
||||
return fmt.Errorf("WatchRepo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = webhook.CopyDefaultWebhooksToRepo(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("CopyDefaultWebhooksToRepo: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupRepository(repo *repo_model.Repository) {
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
if errDelete := DeleteRepositoryDirectly(ctx, repo.ID); errDelete != nil {
|
||||
log.Error("cleanupRepository failed: %v", errDelete)
|
||||
if err := system_model.CreateRepositoryNotice("DeleteRepositoryDirectly failed when cleanup repository (%s)", repo.FullName(), errDelete); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateGitRepoAfterCreate(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if err := CheckDaemonExportOK(ctx, repo); err != nil {
|
||||
return fmt.Errorf("checkDaemonExportOK: %w", err)
|
||||
}
|
||||
|
||||
if stdout, _, err := gitrepo.RunCmdString(ctx, repo,
|
||||
gitcmd.NewCommand("update-server-info")); 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): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateRepositoryDirectly(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// a successful creating repository
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
createdRepo, err := CreateRepositoryDirectly(t.Context(), user2, user2, CreateRepoOptions{
|
||||
Name: "created-repo",
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, createdRepo)
|
||||
|
||||
exist, err := util.IsExist(repo_model.RepoPath(user2.Name, createdRepo.Name))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: user2.Name, Name: createdRepo.Name})
|
||||
|
||||
err = DeleteRepositoryDirectly(t.Context(), createdRepo.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// a failed creating because some mock data
|
||||
// create the repository directory so that the creation will fail after database record created.
|
||||
assert.NoError(t, os.MkdirAll(repo_model.RepoPath(user2.Name, createdRepo.Name), os.ModePerm))
|
||||
|
||||
createdRepo2, err := CreateRepositoryDirectly(t.Context(), user2, user2, CreateRepoOptions{
|
||||
Name: "created-repo",
|
||||
}, true)
|
||||
assert.Nil(t, createdRepo2)
|
||||
assert.Error(t, err)
|
||||
|
||||
// assert the cleanup is successful
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerName: user2.Name, Name: createdRepo.Name})
|
||||
|
||||
exist, err = util.IsExist(repo_model.RepoPath(user2.Name, createdRepo.Name))
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exist)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
admin_model "gitea.dev/models/admin"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
system_model "gitea.dev/models/system"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/models/webhook"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func deleteDBRepository(ctx context.Context, repoID int64) error {
|
||||
if cnt, err := db.GetEngine(ctx).ID(repoID).Delete(&repo_model.Repository{}); err != nil {
|
||||
return err
|
||||
} else if cnt != 1 {
|
||||
return repo_model.ErrRepoNotExist{
|
||||
ID: repoID,
|
||||
OwnerName: "",
|
||||
Name: "",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRepository deletes a repository for a user or organization.
|
||||
// make sure if you call this func to close open sessions (sqlite will otherwise get a deadlock)
|
||||
func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams ...bool) error {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
repo := &repo_model.Repository{}
|
||||
has, err := sess.ID(repoID).Get(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
return repo_model.ErrRepoNotExist{
|
||||
ID: repoID,
|
||||
OwnerName: "",
|
||||
Name: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Query the action tasks of this repo, they will be needed after they have been deleted to remove the logs
|
||||
tasks, err := db.Find[actions_model.ActionTask](ctx, actions_model.FindTaskOptions{RepoID: repoID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find actions tasks of repo %v: %w", repoID, err)
|
||||
}
|
||||
|
||||
// Query the artifacts of this repo, they will be needed after they have been deleted to remove artifacts files in ObjectStorage
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{RepoID: repoID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list actions artifacts of repo %v: %w", repoID, err)
|
||||
}
|
||||
|
||||
// In case owner is a organization, we have to change repo specific teams
|
||||
// if ignoreOrgTeams is not true
|
||||
var org *user_model.User
|
||||
if len(ignoreOrgTeams) == 0 || !ignoreOrgTeams[0] {
|
||||
if org, err = user_model.GetUserByID(ctx, repo.OwnerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Deploy Keys
|
||||
deleted, err := asymkey_service.DeleteRepoDeployKeys(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
needRewriteKeysFile := deleted > 0
|
||||
|
||||
if err := deleteDBRepository(ctx, repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if org != nil && org.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range teams {
|
||||
if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID) {
|
||||
continue
|
||||
} else if err = removeRepositoryFromTeam(ctx, t, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attachments := make([]*repo_model.Attachment, 0, 20)
|
||||
if err = sess.Join("INNER", "`release`", "`release`.id = `attachment`.release_id").
|
||||
Where("`release`.repo_id = ?", repoID).
|
||||
Find(&attachments); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseAttachments := make([]string, 0, len(attachments))
|
||||
for i := 0; i < len(attachments); i++ {
|
||||
releaseAttachments = append(releaseAttachments, attachments[i].RelativePath())
|
||||
}
|
||||
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars=num_stars-1 WHERE id IN (SELECT `uid` FROM `star` WHERE repo_id = ?)", repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).In("hook_id", builder.Select("id").From("webhook").Where(builder.Eq{"webhook.repo_id": repo.ID})).
|
||||
Delete(&webhook.HookTask{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupEphemeralRunnersByPickedTaskOfRepo deletes ephemeral global/org/user that have started any task of this repo
|
||||
// The cannot pick a second task hardening for ephemeral runners expect that task objects remain available until runner deletion
|
||||
// This method will delete affected ephemeral global/org/user runners
|
||||
// &actions_model.ActionRunner{RepoID: repoID} does only handle ephemeral repository runners
|
||||
if err := actions_service.CleanupEphemeralRunnersByPickedTaskOfRepo(ctx, repoID); err != nil {
|
||||
return fmt.Errorf("cleanupEphemeralRunners: %w", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteBeans(ctx,
|
||||
&access_model.Access{RepoID: repo.ID},
|
||||
&activities_model.Action{RepoID: repo.ID},
|
||||
&repo_model.Collaboration{RepoID: repoID},
|
||||
&issues_model.Comment{RefRepoID: repoID},
|
||||
&git_model.CommitStatus{RepoID: repoID},
|
||||
&git_model.Branch{RepoID: repoID},
|
||||
&git_model.LFSLock{RepoID: repoID},
|
||||
&repo_model.LanguageStat{RepoID: repoID},
|
||||
&repo_model.RepoLicense{RepoID: repoID},
|
||||
&issues_model.Milestone{RepoID: repoID},
|
||||
&repo_model.Mirror{RepoID: repoID},
|
||||
&activities_model.Notification{RepoID: repoID},
|
||||
&git_model.ProtectedBranch{RepoID: repoID},
|
||||
&git_model.ProtectedTag{RepoID: repoID},
|
||||
&repo_model.PushMirror{RepoID: repoID},
|
||||
&repo_model.Release{RepoID: repoID},
|
||||
&repo_model.RepoIndexerStatus{RepoID: repoID},
|
||||
&repo_model.Redirect{RedirectRepoID: repoID},
|
||||
&repo_model.RepoUnit{RepoID: repoID},
|
||||
&repo_model.Star{RepoID: repoID},
|
||||
&admin_model.Task{RepoID: repoID},
|
||||
&repo_model.Watch{RepoID: repoID},
|
||||
&webhook.Webhook{RepoID: repoID},
|
||||
&secret_model.Secret{RepoID: repoID},
|
||||
&actions_model.ActionTaskStep{RepoID: repoID},
|
||||
&actions_model.ActionTask{RepoID: repoID},
|
||||
&actions_model.ActionRunJob{RepoID: repoID},
|
||||
&actions_model.ActionRun{RepoID: repoID},
|
||||
&actions_model.ActionRunner{RepoID: repoID},
|
||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||
&actions_model.ActionSchedule{RepoID: repoID},
|
||||
&actions_model.ActionArtifact{RepoID: repoID},
|
||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||
&issues_model.IssuePin{RepoID: repoID},
|
||||
); err != nil {
|
||||
return fmt.Errorf("deleteBeans: %w", err)
|
||||
}
|
||||
|
||||
// Delete Labels and related objects
|
||||
if err := issues_model.DeleteLabelsByRepoID(ctx, repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete Pulls and related objects
|
||||
if err := issues_model.DeletePullsByBaseRepoID(ctx, repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete Issues and related objects
|
||||
var attachmentPaths []string
|
||||
if attachmentPaths, err = issue_service.DeleteIssuesByRepoID(ctx, repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete issue index
|
||||
if err := db.DeleteResourceIndex(ctx, "issue_index", repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if repo.IsFork {
|
||||
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_forks=num_forks-1 WHERE id=?", repo.ForkID); err != nil {
|
||||
return fmt.Errorf("decrease fork count: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", repo.OwnerID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repo.Topics) > 0 {
|
||||
if err := repo_model.RemoveTopicsFromRepo(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := project_model.DeleteProjectByRepoID(ctx, repoID); err != nil {
|
||||
return fmt.Errorf("unable to delete projects for repo[%d]: %w", repoID, err)
|
||||
}
|
||||
|
||||
// Remove LFS objects
|
||||
var lfsObjects []*git_model.LFSMetaObject
|
||||
if err = sess.Where("repository_id=?", repoID).Find(&lfsObjects); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lfsPaths := make([]string, 0, len(lfsObjects))
|
||||
for _, v := range lfsObjects {
|
||||
count, err := db.CountByBean(ctx, &git_model.LFSMetaObject{Pointer: lfs.Pointer{Oid: v.Oid}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
lfsPaths = append(lfsPaths, v.RelativePath())
|
||||
}
|
||||
|
||||
if _, err := db.DeleteByBean(ctx, &git_model.LFSMetaObject{RepositoryID: repoID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove archives
|
||||
var archives []*repo_model.RepoArchiver
|
||||
if err = sess.Where("repo_id=?", repoID).Find(&archives); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
archivePaths := make([]string, 0, len(archives))
|
||||
for _, v := range archives {
|
||||
archivePaths = append(archivePaths, v.RelativePath())
|
||||
}
|
||||
|
||||
if _, err := db.DeleteByBean(ctx, &repo_model.RepoArchiver{RepoID: repoID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if repo.NumForks > 0 {
|
||||
if _, err = sess.Exec("UPDATE `repository` SET fork_id=0,is_fork=? WHERE fork_id=?", false, repo.ID); err != nil {
|
||||
log.Error("reset 'fork_id' and 'is_fork': %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all attachments with both issue_id and release_id are zero
|
||||
var newAttachments []*repo_model.Attachment
|
||||
if err := sess.Where(builder.Eq{
|
||||
"repo_id": repo.ID,
|
||||
"issue_id": 0,
|
||||
"release_id": 0,
|
||||
}).Find(&newAttachments); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newAttachmentPaths := make([]string, 0, len(newAttachments))
|
||||
for _, attach := range newAttachments {
|
||||
newAttachmentPaths = append(newAttachmentPaths, attach.RelativePath())
|
||||
}
|
||||
|
||||
if _, err := sess.Where("repo_id=?", repo.ID).Delete(new(repo_model.Attachment)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unlink packages linked to this repository
|
||||
if err = packages_model.UnlinkRepositoryFromAllPackages(ctx, repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
committer.Close()
|
||||
|
||||
if needRewriteKeysFile {
|
||||
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
|
||||
log.Error("RewriteAllPublicKeys failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// We should always delete the files after the database transaction succeed. If
|
||||
// we delete the file but the database rollback, the repository will be broken.
|
||||
|
||||
// Remove repository files.
|
||||
if err := gitrepo.DeleteRepository(ctx, repo); err != nil {
|
||||
desc := fmt.Sprintf("Delete repository files (%s): %v", repo.FullName(), err)
|
||||
if err = system_model.CreateNotice(graceful.GetManager().ShutdownContext(), system_model.NoticeRepository, desc); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wiki files if it exists.
|
||||
if err := gitrepo.DeleteRepository(ctx, repo.WikiStorageRepo()); err != nil {
|
||||
desc := fmt.Sprintf("Delete wiki repository files (%s): %v", repo.FullName(), err)
|
||||
// Note we use the db.DefaultContext here rather than passing in a context as the context may be cancelled
|
||||
if err = system_model.CreateNotice(graceful.GetManager().ShutdownContext(), system_model.NoticeRepository, desc); err != nil {
|
||||
log.Error("CreateRepositoryNotice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove archives
|
||||
for _, archive := range archivePaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.RepoArchives, "Delete repo archive file", archive)
|
||||
}
|
||||
|
||||
// Remove lfs objects
|
||||
for _, lfsObj := range lfsPaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.LFS, "Delete orphaned LFS file", lfsObj)
|
||||
}
|
||||
|
||||
// Remove issue attachment files.
|
||||
for _, attachment := range attachmentPaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", attachment)
|
||||
}
|
||||
|
||||
// Remove release attachment files.
|
||||
for _, releaseAttachment := range releaseAttachments {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete release attachment", releaseAttachment)
|
||||
}
|
||||
|
||||
// Remove attachment with no issue_id and release_id.
|
||||
for _, newAttachment := range newAttachmentPaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", newAttachment)
|
||||
}
|
||||
|
||||
if len(repo.Avatar) > 0 {
|
||||
if err := storage.RepoAvatars.Delete(repo.CustomAvatarRelativePath()); err != nil {
|
||||
log.Error("remove avatar file %q: %v", repo.CustomAvatarRelativePath(), err)
|
||||
// go on
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, delete action logs after the actions have already been deleted to avoid new log files
|
||||
for _, task := range tasks {
|
||||
err := actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||
if err != nil {
|
||||
log.Error("remove log file %q: %v", task.LogFilename, err)
|
||||
// go on
|
||||
}
|
||||
}
|
||||
|
||||
// delete actions artifacts in ObjectStorage after the repo have already been deleted
|
||||
for _, art := range artifacts {
|
||||
if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil {
|
||||
log.Error("remove artifact file %q: %v", art.StoragePath, err)
|
||||
// go on
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner
|
||||
func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error {
|
||||
for {
|
||||
repos, _, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: repo_model.RepositoryListDefaultPageSize,
|
||||
Page: 1,
|
||||
},
|
||||
Private: true,
|
||||
OwnerID: owner.ID,
|
||||
Actor: owner,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUserRepositories: %w", err)
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
break
|
||||
}
|
||||
for _, repo := range repos {
|
||||
if err := DeleteRepositoryDirectly(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("unable to delete repository %s for %s[%d]. Error: %w", repo.Name, owner.Name, owner.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTeam_HasRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
test := func(teamID, repoID int64, expected bool) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
assert.Equal(t, expected, repo_service.HasRepository(t.Context(), team, repoID))
|
||||
}
|
||||
test(1, 1, false)
|
||||
test(1, 3, true)
|
||||
test(1, 5, true)
|
||||
test(1, unittest.NonexistentID, false)
|
||||
|
||||
test(2, 3, true)
|
||||
test(2, 5, false)
|
||||
}
|
||||
|
||||
func TestTeam_RemoveRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(teamID, repoID int64) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
assert.NoError(t, repo_service.RemoveRepositoryFromTeam(t.Context(), team, repoID))
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID})
|
||||
}
|
||||
testSuccess(2, 3)
|
||||
testSuccess(2, 5)
|
||||
testSuccess(1, unittest.NonexistentID)
|
||||
}
|
||||
|
||||
func TestDeleteOwnerRepositoriesDirectly(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(t.Context(), user))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
// ErrCommitIDDoesNotMatch represents a "CommitIDDoesNotMatch" kind of error.
|
||||
type ErrCommitIDDoesNotMatch struct {
|
||||
GivenCommitID string
|
||||
CurrentCommitID string
|
||||
}
|
||||
|
||||
// IsErrCommitIDDoesNotMatch checks if an error is a ErrCommitIDDoesNotMatch.
|
||||
func IsErrCommitIDDoesNotMatch(err error) bool {
|
||||
_, ok := err.(ErrCommitIDDoesNotMatch)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrCommitIDDoesNotMatch) Error() string {
|
||||
return fmt.Sprintf("file CommitID does not match [given: %s, expected: %s]", err.GivenCommitID, err.CurrentCommitID)
|
||||
}
|
||||
|
||||
// CherryPick cherry-picks or reverts a commit to the given repository
|
||||
func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message := strings.TrimSpace(opts.Message)
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
}
|
||||
defer t.Close()
|
||||
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.RefreshIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the commit of the original branch
|
||||
commit, err := t.GetBranchCommit(opts.OldBranch)
|
||||
if err != nil {
|
||||
return nil, err // Couldn't get a commit for the branch
|
||||
}
|
||||
|
||||
// Assigned LastCommitID in opts if it hasn't been set
|
||||
if opts.LastCommitID == "" {
|
||||
opts.LastCommitID = commit.ID.String()
|
||||
} else {
|
||||
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %w", err)
|
||||
}
|
||||
opts.LastCommitID = lastCommitID.String()
|
||||
if commit.ID.String() != opts.LastCommitID {
|
||||
return nil, ErrCommitIDDoesNotMatch{
|
||||
GivenCommitID: opts.LastCommitID,
|
||||
CurrentCommitID: opts.LastCommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(strings.TrimSpace(opts.Content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parent, err := commit.ParentID(0)
|
||||
if err != nil {
|
||||
parent = git.ObjectFormatFromName(repo.ObjectFormatName).EmptyTree()
|
||||
}
|
||||
|
||||
base, right := parent.String(), commit.ID.String()
|
||||
|
||||
if revert {
|
||||
right, base = base, right
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("CherryPick %s onto %s", right, opts.OldBranch)
|
||||
conflict, _, err := pull.AttemptThreeWayMerge(ctx,
|
||||
t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to three-way merge %s onto %s: %w", right, opts.OldBranch, err)
|
||||
}
|
||||
|
||||
if conflict {
|
||||
return nil, errors.New("failed to merge due to conflicts")
|
||||
}
|
||||
|
||||
treeHash, err := t.WriteTree(ctx)
|
||||
if err != nil {
|
||||
// likely non-sensical tree due to merge conflicts...
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now commit the tree
|
||||
commitOpts := &CommitTreeUserOptions{
|
||||
ParentCommitID: "HEAD",
|
||||
TreeHash: treeHash,
|
||||
CommitMessage: message,
|
||||
SignOff: opts.Signoff,
|
||||
DoerUser: doer,
|
||||
AuthorIdentity: opts.Author,
|
||||
AuthorTime: nil,
|
||||
CommitterIdentity: opts.Committer,
|
||||
CommitterTime: nil,
|
||||
}
|
||||
if opts.Dates != nil {
|
||||
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
|
||||
}
|
||||
commitHash, err := t.CommitTree(ctx, commitOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Then push this tree to NewBranch
|
||||
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(commitHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
|
||||
verification := GetPayloadCommitVerification(ctx, commit)
|
||||
fileResponse := &structs.FileResponse{
|
||||
Commit: fileCommitResponse,
|
||||
Verification: verification,
|
||||
}
|
||||
|
||||
return fileResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/structs"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
)
|
||||
|
||||
// GetPayloadCommitVerification returns the verification information of a commit
|
||||
func GetPayloadCommitVerification(ctx context.Context, commit *git.Commit) *structs.PayloadCommitVerification {
|
||||
verification := &structs.PayloadCommitVerification{}
|
||||
commitVerification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||
if commit.Signature != nil {
|
||||
verification.Signature = commit.Signature.Signature
|
||||
verification.Payload = commit.Signature.Payload
|
||||
}
|
||||
if commitVerification.SigningUser != nil {
|
||||
verification.Signer = &structs.PayloadUser{
|
||||
Name: commitVerification.SigningUser.Name,
|
||||
Email: commitVerification.SigningUser.Email,
|
||||
}
|
||||
}
|
||||
verification.Verified = commitVerification.Verified
|
||||
verification.Reason = commitVerification.Reason
|
||||
if verification.Reason == "" && !verification.Verified {
|
||||
verification.Reason = "gpg.error.not_signed_commit"
|
||||
}
|
||||
return verification
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/v1/utils"
|
||||
)
|
||||
|
||||
// ContentType repo content type
|
||||
type ContentType string
|
||||
|
||||
// The string representations of different content types
|
||||
const (
|
||||
ContentTypeRegular ContentType = "file" // regular content type (file)
|
||||
ContentTypeDir ContentType = "dir" // dir content type (dir)
|
||||
ContentTypeLink ContentType = "symlink" // link content type (symlink)
|
||||
ContentTypeSubmodule ContentType = "submodule" // submodule content type (submodule)
|
||||
)
|
||||
|
||||
type GetContentsOrListOptions struct {
|
||||
TreePath string
|
||||
IncludeSingleFileContent bool // include the file's content when the tree path is a file
|
||||
IncludeLfsMetadata bool
|
||||
IncludeCommitMetadata bool
|
||||
IncludeCommitMessage bool
|
||||
}
|
||||
|
||||
// GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree
|
||||
// directory, otherwise a listing of file contents ([]*ContentsResponse). Ref can be a branch, commit or tag
|
||||
func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, opts GetContentsOrListOptions) (ret api.ContentsExtResponse, _ error) {
|
||||
entry, err := prepareGetContentsEntry(refCommit, &opts.TreePath)
|
||||
if repo.IsEmpty && opts.TreePath == "" {
|
||||
return api.ContentsExtResponse{DirContents: make([]*api.ContentsResponse, 0)}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// get file contents
|
||||
if entry.Type() != "tree" {
|
||||
ret.FileContents, err = getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// list directory contents
|
||||
gitTree, err := refCommit.Commit.SubTree(opts.TreePath)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
entries, err := gitTree.ListEntries()
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
ret.DirContents = make([]*api.ContentsResponse, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
subOpts := opts
|
||||
subOpts.TreePath = path.Join(opts.TreePath, e.Name())
|
||||
subOpts.IncludeSingleFileContent = false // never include file content when listing a directory
|
||||
fileContentResponse, err := GetFileContents(ctx, repo, gitRepo, refCommit, subOpts)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
ret.DirContents = append(ret.DirContents, fileContentResponse)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// GetObjectTypeFromTreeEntry check what content is behind it
|
||||
func GetObjectTypeFromTreeEntry(entry *git.TreeEntry) ContentType {
|
||||
switch {
|
||||
case entry.IsDir():
|
||||
return ContentTypeDir
|
||||
case entry.IsSubModule():
|
||||
return ContentTypeSubmodule
|
||||
case entry.IsExecutable(), entry.IsRegular():
|
||||
return ContentTypeRegular
|
||||
case entry.IsLink():
|
||||
return ContentTypeLink
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func prepareGetContentsEntry(refCommit *utils.RefCommit, treePath *string) (*git.TreeEntry, error) {
|
||||
// Check that the path given in opts.treePath is valid (not a git path)
|
||||
cleanTreePath := CleanGitTreePath(*treePath)
|
||||
if cleanTreePath == "" && *treePath != "" {
|
||||
return nil, ErrFilenameInvalid{Path: *treePath}
|
||||
}
|
||||
*treePath = cleanTreePath
|
||||
|
||||
// Only allow safe ref types
|
||||
refType := refCommit.RefName.RefType()
|
||||
if refType != git.RefTypeBranch && refType != git.RefTypeTag && refType != git.RefTypeCommit {
|
||||
return nil, util.NewNotExistErrorf("no commit found for the ref [ref: %s]", refCommit.RefName)
|
||||
}
|
||||
|
||||
return refCommit.Commit.GetTreeEntryByPath(*treePath)
|
||||
}
|
||||
|
||||
// GetFileContents gets the metadata on a file's contents. Ref can be a branch, commit or tag
|
||||
func GetFileContents(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, opts GetContentsOrListOptions) (*api.ContentsResponse, error) {
|
||||
entry, err := prepareGetContentsEntry(refCommit, &opts.TreePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts)
|
||||
}
|
||||
|
||||
func addLastCommitCache(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, cacheKey, fullName, sha string) error {
|
||||
if gitRepo.LastCommitCache == nil {
|
||||
commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) {
|
||||
return gitrepo.CommitsCountOfCommit(ctx, repo, sha)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gitRepo.LastCommitCache = git.NewLastCommitCache(commitsCount, fullName, gitRepo, cache.GetCache())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileContentsByEntryInternal(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, entry *git.TreeEntry, opts GetContentsOrListOptions) (*api.ContentsResponse, error) {
|
||||
refType := refCommit.RefName.RefType()
|
||||
commit := refCommit.Commit
|
||||
selfURL, err := url.Parse(repo.APIURL() + "/contents/" + util.PathEscapeSegments(opts.TreePath) + "?ref=" + url.QueryEscape(refCommit.InputRef))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selfURLString := selfURL.String()
|
||||
|
||||
// All content types have these fields in populated
|
||||
contentsResponse := &api.ContentsResponse{
|
||||
Name: entry.Name(),
|
||||
Path: opts.TreePath,
|
||||
SHA: entry.ID.String(),
|
||||
Size: entry.Size(),
|
||||
URL: &selfURLString,
|
||||
Links: &api.FileLinksResponse{
|
||||
Self: &selfURLString,
|
||||
},
|
||||
}
|
||||
|
||||
if opts.IncludeCommitMetadata || opts.IncludeCommitMessage {
|
||||
err = addLastCommitCache(ctx, repo, gitRepo, repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.IncludeCommitMetadata {
|
||||
contentsResponse.LastCommitSHA = new(lastCommit.ID.String())
|
||||
// GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them
|
||||
// https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits
|
||||
if lastCommit.Committer != nil {
|
||||
contentsResponse.LastCommitterDate = new(lastCommit.Committer.When)
|
||||
}
|
||||
if lastCommit.Author != nil {
|
||||
contentsResponse.LastAuthorDate = new(lastCommit.Author.When)
|
||||
}
|
||||
}
|
||||
if opts.IncludeCommitMessage {
|
||||
contentsResponse.LastCommitMessage = new(lastCommit.MessageUTF8())
|
||||
}
|
||||
}
|
||||
|
||||
// Now populate the rest of the ContentsResponse based on the entry type
|
||||
if entry.IsRegular() || entry.IsExecutable() {
|
||||
contentsResponse.Type = string(ContentTypeRegular)
|
||||
// if it is listing the repo root dir, don't waste system resources on reading content
|
||||
if opts.IncludeSingleFileContent {
|
||||
blobResponse, err := GetBlobBySHA(repo, gitRepo, entry.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentsResponse.Encoding, contentsResponse.Content = blobResponse.Encoding, blobResponse.Content
|
||||
contentsResponse.LfsOid, contentsResponse.LfsSize = blobResponse.LfsOid, blobResponse.LfsSize
|
||||
} else if opts.IncludeLfsMetadata {
|
||||
contentsResponse.LfsOid, contentsResponse.LfsSize, err = parsePossibleLfsPointerBlob(gitRepo, entry.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if entry.IsDir() {
|
||||
contentsResponse.Type = string(ContentTypeDir)
|
||||
} else if entry.IsLink() {
|
||||
contentsResponse.Type = string(ContentTypeLink)
|
||||
// The target of a symlink file is the content of the file
|
||||
targetFromContent, err := entry.Blob().GetBlobContent(1024)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentsResponse.Target = &targetFromContent
|
||||
} else if entry.IsSubModule() {
|
||||
contentsResponse.Type = string(ContentTypeSubmodule)
|
||||
submodule, err := commit.GetSubModule(opts.TreePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if submodule != nil && submodule.URL != "" {
|
||||
contentsResponse.SubmoduleGitURL = &submodule.URL
|
||||
}
|
||||
}
|
||||
// Handle links
|
||||
if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() {
|
||||
downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloadURLString := downloadURL.String()
|
||||
contentsResponse.DownloadURL = &downloadURLString
|
||||
}
|
||||
if !entry.IsSubModule() {
|
||||
htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
htmlURLString := htmlURL.String()
|
||||
contentsResponse.HTMLURL = &htmlURLString
|
||||
contentsResponse.Links.HTMLURL = &htmlURLString
|
||||
|
||||
gitURL, err := url.Parse(repo.APIURL() + "/git/blobs/" + url.PathEscape(entry.ID.String()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gitURLString := gitURL.String()
|
||||
contentsResponse.GitURL = &gitURLString
|
||||
contentsResponse.Links.GitURL = &gitURLString
|
||||
}
|
||||
|
||||
return contentsResponse, nil
|
||||
}
|
||||
|
||||
func GetBlobBySHA(repo *repo_model.Repository, gitRepo *git.Repository, sha string) (*api.GitBlobResponse, error) {
|
||||
gitBlob, err := gitRepo.GetBlob(sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := &api.GitBlobResponse{
|
||||
SHA: gitBlob.ID.String(),
|
||||
URL: repo.APIURL() + "/git/blobs/" + url.PathEscape(gitBlob.ID.String()),
|
||||
Size: gitBlob.Size(),
|
||||
}
|
||||
|
||||
blobSize := gitBlob.Size()
|
||||
if blobSize > setting.API.DefaultMaxBlobSize {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var originContent *strings.Builder
|
||||
if 0 < blobSize && blobSize < lfs.MetaFileMaxSize {
|
||||
originContent = &strings.Builder{}
|
||||
}
|
||||
|
||||
content, err := gitBlob.GetBlobContentBase64(originContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret.Encoding, ret.Content = new("base64"), &content
|
||||
if originContent != nil {
|
||||
ret.LfsOid, ret.LfsSize = parsePossibleLfsPointerBuffer(strings.NewReader(originContent.String()))
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func parsePossibleLfsPointerBuffer(r io.Reader) (*string, *int64) {
|
||||
p, _ := lfs.ReadPointer(r)
|
||||
if p.IsValid() {
|
||||
return &p.Oid, &p.Size
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parsePossibleLfsPointerBlob(gitRepo *git.Repository, sha string) (*string, *int64, error) {
|
||||
gitBlob, err := gitRepo.GetBlob(sha)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if gitBlob.Size() > lfs.MetaFileMaxSize {
|
||||
return nil, nil, nil // not a LFS pointer
|
||||
}
|
||||
buf, err := gitBlob.GetBlobContent(lfs.MetaFileMaxSize)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
oid, size := parsePossibleLfsPointerBuffer(strings.NewReader(buf))
|
||||
return oid, size, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestGetContents(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.SetPathParam("id", "1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
|
||||
// GetContentsOrList's behavior is fully tested in integration tests, so we don't need to test it here.
|
||||
|
||||
t.Run("GetBlobBySHA", func(t *testing.T) {
|
||||
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
|
||||
ctx.SetPathParam("id", "1")
|
||||
ctx.SetPathParam("sha", sha)
|
||||
gbr, err := GetBlobBySHA(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("sha"))
|
||||
expectedGBR := &api.GitBlobResponse{
|
||||
Content: new("dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"),
|
||||
Encoding: new("base64"),
|
||||
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||
Size: 180,
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedGBR, gbr)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/services/gitdiff"
|
||||
)
|
||||
|
||||
// GetDiffPreview produces and returns diff result of a file which is not yet committed.
|
||||
func GetDiffPreview(ctx context.Context, repo *repo_model.Repository, branch, treePath, oldContent, newContent string) (*gitdiff.Diff, error) {
|
||||
if branch == "" {
|
||||
branch = repo.DefaultBranch
|
||||
}
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer t.Close()
|
||||
if err := t.Clone(ctx, branch, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add the object to the database
|
||||
objectHash, err := t.HashObjectAndWrite(ctx, strings.NewReader(newContent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add the object to the index
|
||||
if err := t.AddObjectToIndex(ctx, "100644", objectHash, treePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.DiffIndex(ctx, oldContent, newContent)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/gitdiff"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetDiffPreview(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.SetPathParam("id", "1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
branch := ctx.Repo.Repository.DefaultBranch
|
||||
treePath := "README.md"
|
||||
oldContent := "# repo1\n\nDescription for repo1"
|
||||
content := "# repo1\n\nDescription for repo1\nthis is a new line"
|
||||
|
||||
t.Run("Errors", func(t *testing.T) {
|
||||
t.Run("empty repo", func(t *testing.T) {
|
||||
diff, err := GetDiffPreview(ctx, &repo_model.Repository{}, branch, treePath, oldContent, content)
|
||||
assert.Nil(t, diff)
|
||||
assert.EqualError(t, err, "repository does not exist [id: 0, uid: 0, owner_name: , name: ]")
|
||||
})
|
||||
|
||||
t.Run("bad branch", func(t *testing.T) {
|
||||
badBranch := "bad_branch"
|
||||
diff, err := GetDiffPreview(ctx, ctx.Repo.Repository, badBranch, treePath, oldContent, content)
|
||||
assert.Nil(t, diff)
|
||||
assert.EqualError(t, err, "branch does not exist [name: "+badBranch+"]")
|
||||
})
|
||||
|
||||
t.Run("empty treePath", func(t *testing.T) {
|
||||
diff, err := GetDiffPreview(ctx, ctx.Repo.Repository, branch, "", oldContent, content)
|
||||
assert.Nil(t, diff)
|
||||
assert.EqualError(t, err, "path is invalid [path: ]")
|
||||
})
|
||||
})
|
||||
|
||||
expectedDiff := &gitdiff.Diff{
|
||||
Files: []*gitdiff.DiffFile{
|
||||
{
|
||||
Name: "README.md",
|
||||
OldName: "README.md",
|
||||
NameHash: "8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d",
|
||||
Addition: 2,
|
||||
Deletion: 1,
|
||||
Type: 2,
|
||||
IsCreated: false,
|
||||
IsDeleted: false,
|
||||
IsBin: false,
|
||||
IsLFSFile: false,
|
||||
IsRenamed: false,
|
||||
IsSubmodule: false,
|
||||
Sections: []*gitdiff.DiffSection{
|
||||
{
|
||||
FileName: "README.md",
|
||||
Lines: []*gitdiff.DiffLine{
|
||||
{
|
||||
LeftIdx: 0,
|
||||
RightIdx: 0,
|
||||
Type: 4,
|
||||
Content: "@@ -1,3 +1,4 @@",
|
||||
Comments: nil,
|
||||
SectionInfo: &gitdiff.DiffLineSectionInfo{
|
||||
Path: "README.md",
|
||||
LastLeftIdx: 0,
|
||||
LastRightIdx: 0,
|
||||
LeftIdx: 1,
|
||||
RightIdx: 1,
|
||||
LeftHunkSize: 3,
|
||||
RightHunkSize: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
LeftIdx: 1,
|
||||
RightIdx: 1,
|
||||
Type: 1,
|
||||
Content: " # repo1",
|
||||
Comments: nil,
|
||||
},
|
||||
{
|
||||
LeftIdx: 2,
|
||||
RightIdx: 2,
|
||||
Type: 1,
|
||||
Content: " ",
|
||||
Comments: nil,
|
||||
},
|
||||
{
|
||||
LeftIdx: 3,
|
||||
RightIdx: 0,
|
||||
Match: 4,
|
||||
Type: 3,
|
||||
Content: "-Description for repo1",
|
||||
Comments: nil,
|
||||
},
|
||||
{
|
||||
LeftIdx: 0,
|
||||
RightIdx: 3,
|
||||
Match: 3,
|
||||
Type: 2,
|
||||
Content: "+Description for repo1",
|
||||
Comments: nil,
|
||||
},
|
||||
{
|
||||
LeftIdx: 0,
|
||||
RightIdx: 4,
|
||||
Match: -1,
|
||||
Type: 2,
|
||||
Content: "+this is a new line",
|
||||
Comments: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
IsIncomplete: false,
|
||||
},
|
||||
},
|
||||
IsIncomplete: false,
|
||||
}
|
||||
|
||||
t.Run("with given branch", func(t *testing.T) {
|
||||
diff, err := GetDiffPreview(ctx, ctx.Repo.Repository, branch, treePath, oldContent, content)
|
||||
assert.NoError(t, err)
|
||||
expectedBs, err := json.Marshal(expectedDiff)
|
||||
assert.NoError(t, err)
|
||||
bs, err := json.Marshal(diff)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedBs), string(bs))
|
||||
})
|
||||
|
||||
t.Run("empty branch, same results", func(t *testing.T) {
|
||||
diff, err := GetDiffPreview(ctx, ctx.Repo.Repository, "", treePath, oldContent, content)
|
||||
assert.NoError(t, err)
|
||||
expectedBs, err := json.Marshal(expectedDiff)
|
||||
assert.NoError(t, err)
|
||||
bs, err := json.Marshal(diff)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedBs), string(bs))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/v1/utils"
|
||||
)
|
||||
|
||||
func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) {
|
||||
var size int64
|
||||
for _, treePath := range treePaths {
|
||||
// ok if fails, then will be nil
|
||||
fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{
|
||||
TreePath: treePath,
|
||||
IncludeSingleFileContent: true,
|
||||
IncludeCommitMetadata: true,
|
||||
})
|
||||
if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" {
|
||||
// if content isn't empty (e.g., due to the single blob being too large), add file size to response size
|
||||
size += int64(len(*fileContents.Content))
|
||||
}
|
||||
if size > setting.API.DefaultMaxResponseSize {
|
||||
break // stop if max response size would be exceeded
|
||||
}
|
||||
files = append(files, fileContents)
|
||||
if len(files) == setting.API.DefaultPagingNum {
|
||||
break // stop if paging num reached
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treeNames []string) (*api.FilesResponse, error) {
|
||||
files := GetContentsListFromTreePaths(ctx, repo, gitRepo, refCommit, treeNames)
|
||||
fileCommitResponse, _ := GetFileCommitResponse(repo, refCommit.Commit) // ok if fails, then will be nil
|
||||
verification := GetPayloadCommitVerification(ctx, refCommit.Commit)
|
||||
filesResponse := &api.FilesResponse{
|
||||
Files: files,
|
||||
Commit: fileCommitResponse,
|
||||
Verification: verification,
|
||||
}
|
||||
return filesResponse, nil
|
||||
}
|
||||
|
||||
// constructs a FileResponse with the file at the index from FilesResponse
|
||||
func GetFileResponseFromFilesResponse(filesResponse *api.FilesResponse, index int) *api.FileResponse {
|
||||
content := &api.ContentsResponse{}
|
||||
if len(filesResponse.Files) > index {
|
||||
content = filesResponse.Files[index]
|
||||
}
|
||||
fileResponse := &api.FileResponse{
|
||||
Content: content,
|
||||
Commit: filesResponse.Commit,
|
||||
Verification: filesResponse.Verification,
|
||||
}
|
||||
return fileResponse
|
||||
}
|
||||
|
||||
// GetFileCommitResponse Constructs a FileCommitResponse from a Commit object
|
||||
func GetFileCommitResponse(repo *repo_model.Repository, commit *git.Commit) (*api.FileCommitResponse, error) {
|
||||
if repo == nil {
|
||||
return nil, errors.New("repo cannot be nil")
|
||||
}
|
||||
if commit == nil {
|
||||
return nil, errors.New("commit cannot be nil")
|
||||
}
|
||||
commitURL, _ := url.Parse(repo.APIURL() + "/git/commits/" + url.PathEscape(commit.ID.String()))
|
||||
commitTreeURL, _ := url.Parse(repo.APIURL() + "/git/trees/" + url.PathEscape(commit.Tree.ID.String()))
|
||||
parents := make([]*api.CommitMeta, commit.ParentCount())
|
||||
for i := 0; i <= commit.ParentCount(); i++ {
|
||||
if parent, err := commit.Parent(i); err == nil && parent != nil {
|
||||
parentCommitURL, _ := url.Parse(repo.APIURL() + "/git/commits/" + url.PathEscape(parent.ID.String()))
|
||||
parents[i] = &api.CommitMeta{
|
||||
SHA: parent.ID.String(),
|
||||
URL: parentCommitURL.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
commitHTMLURL, _ := url.Parse(repo.HTMLURL() + "/commit/" + url.PathEscape(commit.ID.String()))
|
||||
fileCommit := &api.FileCommitResponse{
|
||||
CommitMeta: api.CommitMeta{
|
||||
SHA: commit.ID.String(),
|
||||
URL: commitURL.String(),
|
||||
},
|
||||
HTMLURL: commitHTMLURL.String(),
|
||||
Author: &api.CommitUser{
|
||||
Identity: api.Identity{
|
||||
Name: commit.Author.Name,
|
||||
Email: commit.Author.Email,
|
||||
},
|
||||
Date: commit.Author.When.UTC().Format(time.RFC3339),
|
||||
},
|
||||
Committer: &api.CommitUser{
|
||||
Identity: api.Identity{
|
||||
Name: commit.Committer.Name,
|
||||
Email: commit.Committer.Email,
|
||||
},
|
||||
Date: commit.Committer.When.UTC().Format(time.RFC3339),
|
||||
},
|
||||
Message: commit.MessageUTF8(),
|
||||
Tree: &api.CommitMeta{
|
||||
URL: commitTreeURL.String(),
|
||||
SHA: commit.Tree.ID.String(),
|
||||
},
|
||||
Parents: parents,
|
||||
}
|
||||
return fileCommit, nil
|
||||
}
|
||||
|
||||
// ErrFilenameInvalid represents a "FilenameInvalid" kind of error.
|
||||
type ErrFilenameInvalid struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// IsErrFilenameInvalid checks if an error is an ErrFilenameInvalid.
|
||||
func IsErrFilenameInvalid(err error) bool {
|
||||
_, ok := err.(ErrFilenameInvalid)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrFilenameInvalid) Error() string {
|
||||
return fmt.Sprintf("path contains a malformed path component [path: %s]", err.Path)
|
||||
}
|
||||
|
||||
func (err ErrFilenameInvalid) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// CleanGitTreePath cleans a tree path for git, it returns an empty string the path is invalid (e.g.: contains ".git" part)
|
||||
func CleanGitTreePath(name string) string {
|
||||
name = util.PathJoinRel(name)
|
||||
// Git disallows any filenames to have a .git directory in them.
|
||||
for part := range strings.SplitSeq(name, "/") {
|
||||
if strings.EqualFold(part, ".git") {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if name == "." {
|
||||
name = ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCleanUploadFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
input, expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{".", ""},
|
||||
{"a/./b", "a/b"},
|
||||
{"a.git", "a.git"},
|
||||
{".git/b", ""},
|
||||
{"a/.git", ""},
|
||||
{"/a/../../b", "b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expected, CleanGitTreePath(c.input), "input: %q", c.input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
)
|
||||
|
||||
// ErrUserCannotCommit represents "UserCannotCommit" kind of error.
|
||||
type ErrUserCannotCommit struct {
|
||||
UserName string
|
||||
}
|
||||
|
||||
// IsErrUserCannotCommit checks if an error is an ErrUserCannotCommit.
|
||||
func IsErrUserCannotCommit(err error) bool {
|
||||
_, ok := err.(ErrUserCannotCommit)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUserCannotCommit) Error() string {
|
||||
return fmt.Sprintf("user cannot commit to repo [user: %s]", err.UserName)
|
||||
}
|
||||
|
||||
func (err ErrUserCannotCommit) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ApplyDiffPatchOptions holds the repository diff patch update options
|
||||
type ApplyDiffPatchOptions struct {
|
||||
LastCommitID string
|
||||
OldBranch string
|
||||
NewBranch string
|
||||
Message string
|
||||
Content string
|
||||
Author *IdentityOptions
|
||||
Committer *IdentityOptions
|
||||
Dates *CommitDateOptions
|
||||
Signoff bool
|
||||
}
|
||||
|
||||
// Validate validates the provided options
|
||||
func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User) error {
|
||||
// If no branch name is set, assume master
|
||||
if opts.OldBranch == "" {
|
||||
opts.OldBranch = repo.DefaultBranch
|
||||
}
|
||||
if opts.NewBranch == "" {
|
||||
opts.NewBranch = opts.OldBranch
|
||||
}
|
||||
|
||||
// oldBranch must exist for this operation
|
||||
if exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.OldBranch); err != nil {
|
||||
return err
|
||||
} else if !exist {
|
||||
return git_model.ErrBranchNotExist{
|
||||
BranchName: opts.OldBranch,
|
||||
}
|
||||
}
|
||||
// A NewBranch can be specified for the patch to be applied to.
|
||||
// Check to make sure the branch does not already exist, otherwise we can't proceed.
|
||||
// If we aren't branching to a new branch, make sure user can commit to the given branch
|
||||
if opts.NewBranch != opts.OldBranch {
|
||||
exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.NewBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if exist {
|
||||
return git_model.ErrBranchAlreadyExists{
|
||||
BranchName: opts.NewBranch,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, opts.OldBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if protectedBranch != nil {
|
||||
protectedBranch.Repo = repo
|
||||
if !protectedBranch.CanUserPush(ctx, doer) {
|
||||
return ErrUserCannotCommit{
|
||||
UserName: doer.LowerName,
|
||||
}
|
||||
}
|
||||
}
|
||||
if protectedBranch != nil && protectedBranch.RequireSignedCommits {
|
||||
_, _, _, err := asymkey_service.SignCRUDAction(ctx, doer, gitRepo, opts.OldBranch)
|
||||
if err != nil {
|
||||
if !asymkey_service.IsErrWontSign(err) {
|
||||
return err
|
||||
}
|
||||
return ErrUserCannotCommit{
|
||||
UserName: doer.LowerName,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyDiffPatch applies a patch to the given repository
|
||||
func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(opts.Message)
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
}
|
||||
defer t.Close()
|
||||
if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the commit of the original branch
|
||||
commit, err := t.GetBranchCommit(opts.OldBranch)
|
||||
if err != nil {
|
||||
return nil, err // Couldn't get a commit for the branch
|
||||
}
|
||||
|
||||
// Assigned LastCommitID in opts if it hasn't been set
|
||||
if opts.LastCommitID == "" {
|
||||
opts.LastCommitID = commit.ID.String()
|
||||
} else {
|
||||
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %w", err)
|
||||
}
|
||||
opts.LastCommitID = lastCommitID.String()
|
||||
if commit.ID.String() != opts.LastCommitID {
|
||||
return nil, ErrCommitIDDoesNotMatch{
|
||||
GivenCommitID: opts.LastCommitID,
|
||||
CurrentCommitID: opts.LastCommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
|
||||
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
|
||||
cmdApply.AddArguments("-3")
|
||||
}
|
||||
|
||||
if err := cmdApply.WithDir(t.basePath).
|
||||
WithStdinBytes([]byte(opts.Content)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return nil, fmt.Errorf("git apply error: %w", err)
|
||||
}
|
||||
|
||||
// Now write the tree
|
||||
treeHash, err := t.WriteTree(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now commit the tree
|
||||
commitOpts := &CommitTreeUserOptions{
|
||||
ParentCommitID: "HEAD",
|
||||
TreeHash: treeHash,
|
||||
CommitMessage: message,
|
||||
SignOff: opts.Signoff,
|
||||
DoerUser: doer,
|
||||
AuthorIdentity: opts.Author,
|
||||
AuthorTime: nil,
|
||||
CommitterIdentity: opts.Committer,
|
||||
CommitterTime: nil,
|
||||
}
|
||||
if opts.Dates != nil {
|
||||
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
|
||||
}
|
||||
commitHash, err := t.CommitTree(ctx, commitOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Then push this tree to NewBranch
|
||||
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(commitHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
|
||||
verification := GetPayloadCommitVerification(ctx, commit)
|
||||
fileResponse := &structs.FileResponse{
|
||||
Commit: fileCommitResponse,
|
||||
Verification: verification,
|
||||
}
|
||||
|
||||
return fileResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
"gitea.dev/services/gitdiff"
|
||||
)
|
||||
|
||||
// TemporaryUploadRepository is a type to wrap our upload repositories as a shallow clone
|
||||
type TemporaryUploadRepository struct {
|
||||
repo *repo_model.Repository
|
||||
gitRepo *git.Repository
|
||||
basePath string
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// NewTemporaryUploadRepository creates a new temporary upload repository
|
||||
func NewTemporaryUploadRepository(repo *repo_model.Repository) (*TemporaryUploadRepository, error) {
|
||||
basePath, cleanup, err := repo_module.CreateTemporaryPath("upload")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &TemporaryUploadRepository{repo: repo, basePath: basePath, cleanup: cleanup}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Close the repository cleaning up all files
|
||||
func (t *TemporaryUploadRepository) Close() {
|
||||
defer t.gitRepo.Close()
|
||||
if t.cleanup != nil {
|
||||
t.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the base repository to our path and set branch as the HEAD
|
||||
func (t *TemporaryUploadRepository) Clone(ctx context.Context, branch string, bare bool) error {
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, t.repo, t.basePath, git.CloneRepoOptions{
|
||||
Bare: bare,
|
||||
Branch: branch,
|
||||
Shared: true,
|
||||
}); err != nil {
|
||||
stderr := err.Error()
|
||||
if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched {
|
||||
return git.ErrBranchNotExist{
|
||||
Name: branch,
|
||||
}
|
||||
} else if matched, _ := regexp.MatchString(".* repository .* does not exist.*", stderr); matched {
|
||||
return repo_model.ErrRepoNotExist{
|
||||
ID: t.repo.ID,
|
||||
UID: t.repo.OwnerID,
|
||||
OwnerName: t.repo.OwnerName,
|
||||
Name: t.repo.Name,
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Clone: %w %s", err, stderr)
|
||||
}
|
||||
gitRepo, err := git.OpenRepository(ctx, t.basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.gitRepo = gitRepo
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init the repository
|
||||
func (t *TemporaryUploadRepository) Init(ctx context.Context, objectFormatName string) error {
|
||||
if err := git.InitRepository(ctx, t.basePath, false, objectFormatName); err != nil {
|
||||
return err
|
||||
}
|
||||
gitRepo, err := git.OpenRepository(ctx, t.basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.gitRepo = gitRepo
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDefaultIndex sets the git index to our HEAD
|
||||
func (t *TemporaryUploadRepository) SetDefaultIndex(ctx context.Context) error {
|
||||
if err := gitcmd.NewCommand("read-tree", "HEAD").WithDir(t.basePath).RunWithStderr(ctx); err != nil {
|
||||
return fmt.Errorf("SetDefaultIndex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshIndex looks at the current index and checks to see if merges or updates are needed by checking stat() information.
|
||||
func (t *TemporaryUploadRepository) RefreshIndex(ctx context.Context) error {
|
||||
if err := gitcmd.NewCommand("update-index", "--refresh").WithDir(t.basePath).RunWithStderr(ctx); err != nil {
|
||||
return fmt.Errorf("RefreshIndex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LsFiles checks if the given filename arguments are in the index
|
||||
func (t *TemporaryUploadRepository) LsFiles(ctx context.Context, filenames ...string) ([]string, error) {
|
||||
stdOut := new(bytes.Buffer)
|
||||
if err := gitcmd.NewCommand("ls-files", "-z").AddDashesAndList(filenames...).
|
||||
WithDir(t.basePath).
|
||||
WithStdoutBuffer(stdOut).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return nil, fmt.Errorf("unable to run git ls-files for temporary repo of: %s, error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
|
||||
fileList := make([]string, 0, len(filenames))
|
||||
for line := range bytes.SplitSeq(stdOut.Bytes(), []byte{'\000'}) {
|
||||
fileList = append(fileList, string(line))
|
||||
}
|
||||
|
||||
return fileList, nil
|
||||
}
|
||||
|
||||
func (t *TemporaryUploadRepository) RemoveRecursivelyFromIndex(ctx context.Context, path string) error {
|
||||
_, _, err := gitcmd.NewCommand("rm", "--cached", "-r").
|
||||
AddDynamicArguments(path).
|
||||
WithDir(t.basePath).
|
||||
RunStdBytes(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveFilesFromIndex removes the given files from the index
|
||||
func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error {
|
||||
objFmt, err := t.gitRepo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get object format for temporary repo: %q, error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
stdIn := new(bytes.Buffer)
|
||||
for _, file := range filenames {
|
||||
if file != "" {
|
||||
// man git-update-index: input syntax (1): mode SP sha1 TAB path
|
||||
// mode=0 means "remove from index", then hash part "does not matter as long as it is well formatted."
|
||||
_, _ = fmt.Fprintf(stdIn, "0 %s\t%s\x00", objFmt.EmptyObjectID(), file)
|
||||
}
|
||||
}
|
||||
|
||||
if err := gitcmd.NewCommand("update-index", "--remove", "-z", "--index-info").
|
||||
WithDir(t.basePath).
|
||||
WithStdinBytes(stdIn.Bytes()).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return fmt.Errorf("unable to update-index for temporary repo: %q, error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashObjectAndWrite writes the provided content to the object db and returns its hash
|
||||
func (t *TemporaryUploadRepository) HashObjectAndWrite(ctx context.Context, content io.Reader) (string, error) {
|
||||
stdOut := new(bytes.Buffer)
|
||||
if err := gitcmd.NewCommand("hash-object", "-w", "--stdin").
|
||||
WithDir(t.basePath).
|
||||
WithStdoutBuffer(stdOut).
|
||||
WithStdinCopy(content).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return "", fmt.Errorf("unable to hash-object to temporary repo: %s, error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(stdOut.String()), nil
|
||||
}
|
||||
|
||||
// AddObjectToIndex adds the provided object hash to the index with the provided mode and path
|
||||
func (t *TemporaryUploadRepository) AddObjectToIndex(ctx context.Context, mode, objectHash, objectPath string) error {
|
||||
cmd := gitcmd.NewCommand("update-index", "--add", "--replace", "--cacheinfo").
|
||||
AddDynamicArguments(mode + "," + objectHash + "," + objectPath).WithDir(t.basePath)
|
||||
if err := cmd.RunWithStderr(ctx); err != nil {
|
||||
if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Stderr()); matched {
|
||||
return ErrFilePathInvalid{
|
||||
Message: objectPath,
|
||||
Path: objectPath,
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unable to add object to index at %s in temporary repo %s, error: %w", objectPath, t.repo.FullName(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteTree writes the current index as a tree to the object db and returns its hash
|
||||
func (t *TemporaryUploadRepository) WriteTree(ctx context.Context) (string, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("write-tree").WithDir(t.basePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
log.Error("Unable to write tree in temporary repo: %s(%s): Error: %v", t.repo.FullName(), t.basePath, err)
|
||||
return "", fmt.Errorf("Unable to write-tree in temporary repo for: %s Error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
return strings.TrimSpace(stdout), nil
|
||||
}
|
||||
|
||||
// GetLastCommit gets the last commit ID SHA of the repo
|
||||
func (t *TemporaryUploadRepository) GetLastCommit(ctx context.Context) (string, error) {
|
||||
return t.GetLastCommitByRef(ctx, "HEAD")
|
||||
}
|
||||
|
||||
// GetLastCommitByRef gets the last commit ID SHA of the repo by ref
|
||||
func (t *TemporaryUploadRepository) GetLastCommitByRef(ctx context.Context, ref string) (string, error) {
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
stdout, _, err := gitcmd.NewCommand("rev-parse").AddDynamicArguments(ref).WithDir(t.basePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
log.Error("Unable to get last ref for %s in temporary repo: %s(%s): Error: %v", ref, t.repo.FullName(), t.basePath, err)
|
||||
return "", fmt.Errorf("Unable to rev-parse %s in temporary repo for: %s Error: %w", ref, t.repo.FullName(), err)
|
||||
}
|
||||
return strings.TrimSpace(stdout), nil
|
||||
}
|
||||
|
||||
type CommitTreeUserOptions struct {
|
||||
ParentCommitID string
|
||||
TreeHash string
|
||||
CommitMessage string
|
||||
SignOff bool
|
||||
|
||||
DoerUser *user_model.User
|
||||
|
||||
AuthorIdentity *IdentityOptions // if nil, use doer
|
||||
AuthorTime *time.Time // if nil, use now
|
||||
CommitterIdentity *IdentityOptions
|
||||
CommitterTime *time.Time
|
||||
}
|
||||
|
||||
func makeGitUserSignature(doer *user_model.User, identity, other *IdentityOptions) *git.Signature {
|
||||
gitSig := &git.Signature{}
|
||||
if identity != nil {
|
||||
gitSig.Name, gitSig.Email = identity.GitUserName, identity.GitUserEmail
|
||||
}
|
||||
if other != nil {
|
||||
gitSig.Name = util.IfZero(gitSig.Name, other.GitUserName)
|
||||
gitSig.Email = util.IfZero(gitSig.Email, other.GitUserEmail)
|
||||
}
|
||||
if gitSig.Name == "" {
|
||||
gitSig.Name = doer.GitName()
|
||||
}
|
||||
if gitSig.Email == "" {
|
||||
gitSig.Email = doer.GetEmail()
|
||||
}
|
||||
return gitSig
|
||||
}
|
||||
|
||||
// CommitTree creates a commit from a given tree for the user with provided message
|
||||
func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *CommitTreeUserOptions) (string, error) {
|
||||
authorSig := makeGitUserSignature(opts.DoerUser, opts.AuthorIdentity, opts.CommitterIdentity)
|
||||
committerSig := makeGitUserSignature(opts.DoerUser, opts.CommitterIdentity, opts.AuthorIdentity)
|
||||
|
||||
authorDate := opts.AuthorTime
|
||||
committerDate := opts.CommitterTime
|
||||
if authorDate == nil && committerDate == nil {
|
||||
authorDate = new(time.Now())
|
||||
committerDate = authorDate
|
||||
} else if authorDate == nil {
|
||||
authorDate = committerDate
|
||||
} else if committerDate == nil {
|
||||
committerDate = authorDate
|
||||
}
|
||||
|
||||
// Because this may call hooks we should pass in the environment
|
||||
env := append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+authorSig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+authorSig.Email,
|
||||
"GIT_AUTHOR_DATE="+authorDate.Format(time.RFC3339),
|
||||
"GIT_COMMITTER_DATE="+committerDate.Format(time.RFC3339),
|
||||
)
|
||||
|
||||
messageBytes := new(bytes.Buffer)
|
||||
_, _ = messageBytes.WriteString(opts.CommitMessage)
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
|
||||
cmdCommitTree := gitcmd.NewCommand("commit-tree").AddDynamicArguments(opts.TreeHash)
|
||||
if opts.ParentCommitID != "" {
|
||||
cmdCommitTree.AddOptionValues("-p", opts.ParentCommitID)
|
||||
}
|
||||
|
||||
var sign bool
|
||||
var key *git.SigningKey
|
||||
var signer *git.Signature
|
||||
if opts.ParentCommitID != "" {
|
||||
sign, key, signer, _ = asymkey_service.SignCRUDAction(ctx, opts.DoerUser, t.gitRepo, opts.ParentCommitID)
|
||||
} else {
|
||||
sign, key, signer, _ = asymkey_service.SignInitialCommit(ctx, opts.DoerUser)
|
||||
}
|
||||
if sign {
|
||||
if key.Format != "" {
|
||||
cmdCommitTree.AddConfig("gpg.format", key.Format)
|
||||
}
|
||||
cmdCommitTree.AddOptionFormat("-S%s", key.KeyID)
|
||||
if t.repo.GetTrustModel() == repo_model.CommitterTrustModel || t.repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
if committerSig.Name != authorSig.Name || committerSig.Email != authorSig.Email {
|
||||
// Add trailers
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
_, _ = messageBytes.WriteString("Co-authored-by: ")
|
||||
_, _ = messageBytes.WriteString(committerSig.String())
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
_, _ = messageBytes.WriteString("Co-committed-by: ")
|
||||
_, _ = messageBytes.WriteString(committerSig.String())
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
}
|
||||
committerSig = signer
|
||||
}
|
||||
} else {
|
||||
cmdCommitTree.AddArguments("--no-gpg-sign")
|
||||
}
|
||||
|
||||
if opts.SignOff {
|
||||
// Signed-off-by
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
_, _ = messageBytes.WriteString("Signed-off-by: ")
|
||||
_, _ = messageBytes.WriteString(committerSig.String())
|
||||
}
|
||||
|
||||
env = append(env,
|
||||
"GIT_COMMITTER_NAME="+committerSig.Name,
|
||||
"GIT_COMMITTER_EMAIL="+committerSig.Email,
|
||||
)
|
||||
|
||||
stdout := new(bytes.Buffer)
|
||||
if err := cmdCommitTree.
|
||||
WithEnv(env).
|
||||
WithDir(t.basePath).
|
||||
WithStdoutBuffer(stdout).
|
||||
WithStdinBytes(messageBytes.Bytes()).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return "", fmt.Errorf("unable to commit-tree in temporary repo: %s Error: %w", t.repo.FullName(), err)
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// Push the provided commitHash to the repository branch by the provided user
|
||||
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string, force bool) error {
|
||||
// Because calls hooks we need to pass in the environment
|
||||
env := repo_module.PushingEnvironment(doer, t.repo)
|
||||
if err := gitrepo.PushFromLocal(ctx, t.basePath, t.repo, git.PushOptions{
|
||||
Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch),
|
||||
Env: env,
|
||||
Force: force,
|
||||
}); err != nil {
|
||||
if git.IsErrPushOutOfDate(err) {
|
||||
return err
|
||||
} else if git.IsErrPushRejected(err) {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to push back to repo from temporary repo: %s (%s)\nError: %v",
|
||||
t.repo.FullName(), t.basePath, err)
|
||||
return fmt.Errorf("Unable to push back to repo from temporary repo: %s (%s) Error: %v",
|
||||
t.repo.FullName(), t.basePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiffIndex returns a Diff of the current index to the head
|
||||
func (t *TemporaryUploadRepository) DiffIndex(ctx context.Context, oldContent, newContent string) (*gitdiff.Diff, error) {
|
||||
var diff *gitdiff.Diff
|
||||
cmd := gitcmd.NewCommand("diff-index", "--src-prefix=\\a/", "--dst-prefix=\\b/", "--cached", "-p", "HEAD")
|
||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
|
||||
err := cmd.WithTimeout(30 * time.Second).
|
||||
WithDir(t.basePath).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
var diffErr error
|
||||
diff, diffErr = gitdiff.ParsePatch(ctx, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, stdoutReader, "")
|
||||
if diffErr != nil {
|
||||
// if the diffErr is not nil, it will be returned as the error of "Run()"
|
||||
return fmt.Errorf("ParsePatch: %w", diffErr)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
RunWithStderr(ctx)
|
||||
if err != nil && !gitcmd.IsErrorCanceledOrKilled(err) {
|
||||
return nil, fmt.Errorf("unable to run diff-index pipeline in temporary repo: %w", err)
|
||||
}
|
||||
|
||||
if len(diff.Files) > 0 {
|
||||
gitdiff.FillDiffFileHighlightLinesByContent(diff.Files[0], util.UnsafeStringToBytes(oldContent), util.UnsafeStringToBytes(newContent))
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// GetBranchCommit Gets the commit object of the given branch
|
||||
func (t *TemporaryUploadRepository) GetBranchCommit(branch string) (*git.Commit, error) {
|
||||
if t.gitRepo == nil {
|
||||
return nil, errors.New("repository has not been cloned")
|
||||
}
|
||||
return t.gitRepo.GetBranchCommit(branch)
|
||||
}
|
||||
|
||||
// GetCommit Gets the commit object of the given commit ID
|
||||
func (t *TemporaryUploadRepository) GetCommit(commitID string) (*git.Commit, error) {
|
||||
if t.gitRepo == nil {
|
||||
return nil, errors.New("repository has not been cloned")
|
||||
}
|
||||
return t.gitRepo.GetCommit(commitID)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTemporaryUploadRepository(t *testing.T) {
|
||||
mockedRepo := &repo_model.Repository{Name: "mocked-repo-name", OwnerName: "mocked-owner-name"}
|
||||
|
||||
doTest := func(t *testing.T, objectFormatName string) {
|
||||
tmpGitRepo, err := NewTemporaryUploadRepository(mockedRepo)
|
||||
require.NoError(t, err)
|
||||
defer tmpGitRepo.Close()
|
||||
|
||||
require.NoError(t, tmpGitRepo.Init(t.Context(), objectFormatName))
|
||||
|
||||
require.NoError(t, tmpGitRepo.RemoveFilesFromIndex(t.Context(), "any-file-name"))
|
||||
require.NoError(t, tmpGitRepo.RemoveFilesFromIndex(t.Context(), "--any-file-name"))
|
||||
|
||||
objID, err := tmpGitRepo.HashObjectAndWrite(t.Context(), bytes.NewReader(nil))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpGitRepo.AddObjectToIndex(t.Context(), "100644", objID, "any-file-name"))
|
||||
require.NoError(t, tmpGitRepo.AddObjectToIndex(t.Context(), "100644", objID, "--any-file-name"))
|
||||
}
|
||||
|
||||
t.Run("sha1", func(t *testing.T) {
|
||||
doTest(t, git.Sha1ObjectFormat.Name())
|
||||
})
|
||||
|
||||
t.Run("sha256", func(t *testing.T) {
|
||||
if !git.DefaultFeatures().SupportHashSha256 {
|
||||
t.Skip("sha256 is not supported")
|
||||
}
|
||||
doTest(t, git.Sha256ObjectFormat.Name())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/fileicon"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrSHANotFound represents a "SHADoesNotMatch" kind of error.
|
||||
type ErrSHANotFound struct {
|
||||
SHA string
|
||||
}
|
||||
|
||||
func (err ErrSHANotFound) Error() string {
|
||||
return fmt.Sprintf("sha not found [%s]", err.SHA)
|
||||
}
|
||||
|
||||
func (err ErrSHANotFound) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// GetTreeBySHA get the GitTreeResponse of a repository using a sha hash.
|
||||
func GetTreeBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, sha string, page, perPage int, recursive bool) (*api.GitTreeResponse, error) {
|
||||
gitTree, err := gitRepo.GetTree(sha)
|
||||
if err != nil || gitTree == nil {
|
||||
return nil, ErrSHANotFound{ // TODO: this error has never been catch outside of this function
|
||||
SHA: sha,
|
||||
}
|
||||
}
|
||||
tree := new(api.GitTreeResponse)
|
||||
tree.SHA = gitTree.ResolvedID.String()
|
||||
tree.URL = repo.APIURL() + "/git/trees/" + url.PathEscape(tree.SHA)
|
||||
var entries git.Entries
|
||||
if recursive {
|
||||
entries, err = gitTree.ListEntriesRecursiveWithSize()
|
||||
} else {
|
||||
entries, err = gitTree.ListEntries()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL := repo.APIURL()
|
||||
blobURLBase := apiURL + "/git/blobs/"
|
||||
treeURLBase := apiURL + "/git/trees/"
|
||||
|
||||
if perPage <= 0 || perPage > setting.API.DefaultGitTreesPerPage {
|
||||
perPage = setting.API.DefaultGitTreesPerPage
|
||||
}
|
||||
page = max(page, 1)
|
||||
|
||||
tree.Page = page
|
||||
tree.TotalCount = len(entries)
|
||||
rangeStart := perPage * (page - 1) // int might overflow
|
||||
if rangeStart < 0 || rangeStart >= len(entries) {
|
||||
return tree, nil
|
||||
}
|
||||
rangeEnd := min(rangeStart+perPage, len(entries))
|
||||
tree.Truncated = rangeEnd < len(entries)
|
||||
tree.Entries = make([]api.GitEntry, rangeEnd-rangeStart)
|
||||
for e := rangeStart; e < rangeEnd; e++ {
|
||||
i := e - rangeStart
|
||||
|
||||
tree.Entries[i].Path = entries[e].Name()
|
||||
tree.Entries[i].Mode = fmt.Sprintf("%06o", entries[e].Mode())
|
||||
tree.Entries[i].Type = entries[e].Type()
|
||||
tree.Entries[i].Size = entries[e].Size()
|
||||
tree.Entries[i].SHA = entries[e].ID.String()
|
||||
|
||||
if entries[e].IsDir() {
|
||||
tree.Entries[i].URL = treeURLBase + entries[e].ID.String()
|
||||
} else if entries[e].IsSubModule() {
|
||||
// In GitHub Rest API Version=2022-11-28, if a tree entry is a submodule,
|
||||
// its url will be returned as an empty string.
|
||||
// So the URL will be set to "" here.
|
||||
tree.Entries[i].URL = ""
|
||||
} else {
|
||||
tree.Entries[i].URL = blobURLBase + entries[e].ID.String()
|
||||
}
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func entryModeString(entryMode git.EntryMode) string {
|
||||
switch entryMode {
|
||||
case git.EntryModeBlob:
|
||||
return "blob"
|
||||
case git.EntryModeExec:
|
||||
return "exec"
|
||||
case git.EntryModeSymlink:
|
||||
return "symlink"
|
||||
case git.EntryModeCommit:
|
||||
return "commit" // submodule
|
||||
case git.EntryModeTree:
|
||||
return "tree"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type TreeViewNode struct {
|
||||
EntryName string `json:"entryName"`
|
||||
EntryMode string `json:"entryMode"`
|
||||
EntryIcon template.HTML `json:"entryIcon"`
|
||||
EntryIconOpen template.HTML `json:"entryIconOpen,omitempty"`
|
||||
|
||||
SymLinkedToMode string `json:"symLinkedToMode,omitempty"` // TODO: for the EntryMode="symlink"
|
||||
|
||||
FullPath string `json:"fullPath"`
|
||||
SubmoduleURL string `json:"submoduleUrl,omitempty"`
|
||||
Children []*TreeViewNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (node *TreeViewNode) sortLevel() int {
|
||||
return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1)
|
||||
}
|
||||
|
||||
func newTreeViewNodeFromEntry(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode {
|
||||
node := &TreeViewNode{
|
||||
EntryName: entry.Name(),
|
||||
EntryMode: entryModeString(entry.Mode()),
|
||||
FullPath: path.Join(parentDir, entry.Name()),
|
||||
}
|
||||
|
||||
entryInfo := fileicon.EntryInfoFromGitTreeEntry(commit, node.FullPath, entry)
|
||||
node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
|
||||
if entryInfo.EntryMode.IsDir() {
|
||||
entryInfo.IsOpen = true
|
||||
node.EntryIconOpen = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
|
||||
}
|
||||
|
||||
if node.EntryMode == "commit" {
|
||||
if subModule, err := commit.GetSubModule(node.FullPath); err != nil {
|
||||
log.Error("GetSubModule: %v", err)
|
||||
} else if subModule != nil {
|
||||
submoduleFile := git.NewCommitSubmoduleFile(repoLink, node.FullPath, subModule.URL, entry.ID.String())
|
||||
webLink := submoduleFile.SubmoduleWebLinkTree(ctx)
|
||||
if webLink != nil {
|
||||
node.SubmoduleURL = webLink.CommitWebLink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// sortTreeViewNodes list directory first and with alpha sequence
|
||||
func sortTreeViewNodes(nodes []*TreeViewNode) {
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
a, b := nodes[i].sortLevel(), nodes[j].sortLevel()
|
||||
if a != b {
|
||||
return a < b
|
||||
}
|
||||
return base.NaturalSortCompare(nodes[i].EntryName, nodes[j].EntryName) < 0
|
||||
})
|
||||
}
|
||||
|
||||
func listTreeNodes(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) {
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/")
|
||||
nodes := make([]*TreeViewNode, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
node := newTreeViewNodeFromEntry(ctx, repoLink, renderedIconPool, commit, treePath, entry)
|
||||
nodes = append(nodes, node)
|
||||
if entry.IsDir() && subPathDirName == entry.Name() {
|
||||
subTreePath := treePath + "/" + node.EntryName
|
||||
if subTreePath[0] == '/' {
|
||||
subTreePath = subTreePath[1:]
|
||||
}
|
||||
subNodes, err := listTreeNodes(ctx, repoLink, renderedIconPool, commit, entry.Tree(), subTreePath, subPathRemaining)
|
||||
if err != nil {
|
||||
log.Error("listTreeNodes: %v", err)
|
||||
} else {
|
||||
node.Children = subNodes
|
||||
}
|
||||
}
|
||||
}
|
||||
sortTreeViewNodes(nodes)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func GetTreeViewNodes(ctx context.Context, repoLink string, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) {
|
||||
entry, err := commit.GetTreeEntryByPath(treePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listTreeNodes(ctx, repoLink, renderedIconPool, commit, entry.Tree(), treePath, subPath)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/fileicon"
|
||||
"gitea.dev/modules/git"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetTreeBySHA(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
sha := ctx.Repo.Repository.DefaultBranch
|
||||
page := 1
|
||||
perPage := 10
|
||||
ctx.SetPathParam("id", "1")
|
||||
ctx.SetPathParam("sha", sha)
|
||||
|
||||
tree, err := GetTreeBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("sha"), page, perPage, true)
|
||||
assert.NoError(t, err)
|
||||
expectedTree := &api.GitTreeResponse{
|
||||
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||
Entries: []api.GitEntry{
|
||||
{
|
||||
Path: "README.md",
|
||||
Mode: "100644",
|
||||
Type: "blob",
|
||||
Size: 30,
|
||||
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
|
||||
URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f",
|
||||
},
|
||||
},
|
||||
Truncated: false,
|
||||
Page: 1,
|
||||
TotalCount: 1,
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedTree, tree)
|
||||
}
|
||||
|
||||
func TestGetTreeViewNodes(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.Repo.RefFullName = git.RefNameFromBranch("sub-home-md-img-check")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
curRepoLink := "/any/repo-link"
|
||||
renderedIconPool := fileicon.NewRenderedIconPool()
|
||||
mockIconForFile := func(id string) template.HTML {
|
||||
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use href="#` + id + `"></use></svg>`)
|
||||
}
|
||||
mockIconForFolder := func() template.HTML {
|
||||
// With basic theme (default for folders), we get octicon icons without IDs
|
||||
return template.HTML(`<span>octicon-file-directory-fill(16/)</span>`)
|
||||
}
|
||||
mockOpenIconForFolder := func() template.HTML {
|
||||
// With basic theme (default for folders), we get octicon icons without IDs
|
||||
return template.HTML(`<span>octicon-file-directory-open-fill(16/)</span>`)
|
||||
}
|
||||
treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*TreeViewNode{
|
||||
{
|
||||
EntryName: "docs",
|
||||
EntryMode: "tree",
|
||||
FullPath: "docs",
|
||||
EntryIcon: mockIconForFolder(),
|
||||
EntryIconOpen: mockOpenIconForFolder(),
|
||||
},
|
||||
}, treeNodes)
|
||||
|
||||
treeNodes, err = GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "docs/README.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*TreeViewNode{
|
||||
{
|
||||
EntryName: "docs",
|
||||
EntryMode: "tree",
|
||||
FullPath: "docs",
|
||||
EntryIcon: mockIconForFolder(),
|
||||
EntryIconOpen: mockOpenIconForFolder(),
|
||||
Children: []*TreeViewNode{
|
||||
{
|
||||
EntryName: "README.md",
|
||||
EntryMode: "blob",
|
||||
FullPath: "docs/README.md",
|
||||
EntryIcon: mockIconForFile(`svg-mfi-readme`),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, treeNodes)
|
||||
|
||||
treeNodes, err = GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "docs", "README.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*TreeViewNode{
|
||||
{
|
||||
EntryName: "README.md",
|
||||
EntryMode: "blob",
|
||||
FullPath: "docs/README.md",
|
||||
EntryIcon: mockIconForFile(`svg-mfi-readme`),
|
||||
},
|
||||
}, treeNodes)
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/attribute"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/v1/utils"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
// IdentityOptions for a person's identity like an author or committer
|
||||
type IdentityOptions struct {
|
||||
GitUserName string // to match "git config user.name"
|
||||
GitUserEmail string // to match "git config user.email"
|
||||
}
|
||||
|
||||
// CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE
|
||||
type CommitDateOptions struct {
|
||||
Author time.Time
|
||||
Committer time.Time
|
||||
}
|
||||
|
||||
type ChangeRepoFile struct {
|
||||
Operation string
|
||||
TreePath string
|
||||
FromTreePath string
|
||||
ContentReader io.ReadSeeker
|
||||
SHA string
|
||||
|
||||
DeleteRecursively bool // when deleting, work as `git rm -r ...`
|
||||
|
||||
Options *RepoFileOptions // FIXME: need to refactor, internal usage only
|
||||
}
|
||||
|
||||
// ChangeRepoFilesOptions holds the repository files update options
|
||||
type ChangeRepoFilesOptions struct {
|
||||
LastCommitID string
|
||||
OldBranch string
|
||||
NewBranch string
|
||||
Message string
|
||||
Files []*ChangeRepoFile
|
||||
Author *IdentityOptions
|
||||
Committer *IdentityOptions
|
||||
Dates *CommitDateOptions
|
||||
Signoff bool
|
||||
ForcePush bool
|
||||
}
|
||||
|
||||
type RepoFileOptions struct {
|
||||
treePath string
|
||||
fromTreePath string
|
||||
executable bool
|
||||
}
|
||||
|
||||
type LazyReadSeeker interface {
|
||||
io.ReadSeeker
|
||||
io.Closer
|
||||
OpenLazyReader() error
|
||||
}
|
||||
|
||||
// ChangeRepoFiles adds, updates or removes multiple files in the given repository
|
||||
func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (_ *structs.FilesResponse, errRet error) {
|
||||
var addedLfsPointers []lfs.Pointer
|
||||
defer func() {
|
||||
if errRet != nil {
|
||||
for _, lfsPointer := range addedLfsPointers {
|
||||
_, err := git_model.RemoveLFSMetaObjectByOid(ctx, repo.ID, lfsPointer.Oid)
|
||||
if err != nil {
|
||||
log.Error("ChangeRepoFiles: RemoveLFSMetaObjectByOid failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no branch name is set, assume the default branch
|
||||
if opts.OldBranch == "" {
|
||||
opts.OldBranch = repo.DefaultBranch
|
||||
}
|
||||
if opts.NewBranch == "" {
|
||||
opts.NewBranch = opts.OldBranch
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
// oldBranch must exist for this operation
|
||||
if exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.OldBranch); err != nil {
|
||||
return nil, err
|
||||
} else if !exist && !repo.IsEmpty {
|
||||
return nil, git_model.ErrBranchNotExist{
|
||||
RepoID: repo.ID,
|
||||
BranchName: opts.OldBranch,
|
||||
}
|
||||
}
|
||||
|
||||
var treePaths []string
|
||||
for _, file := range opts.Files {
|
||||
// If FromTreePath is not set, set it to the opts.TreePath
|
||||
if file.TreePath != "" && file.FromTreePath == "" {
|
||||
file.FromTreePath = file.TreePath
|
||||
}
|
||||
|
||||
// Check that the path given in opts.treePath is valid (not a git path)
|
||||
treePath := CleanGitTreePath(file.TreePath)
|
||||
if treePath == "" {
|
||||
return nil, ErrFilenameInvalid{
|
||||
Path: file.TreePath,
|
||||
}
|
||||
}
|
||||
// If there is a fromTreePath (we are copying it), also clean it up
|
||||
fromTreePath := CleanGitTreePath(file.FromTreePath)
|
||||
if fromTreePath == "" && file.FromTreePath != "" {
|
||||
return nil, ErrFilenameInvalid{
|
||||
Path: file.FromTreePath,
|
||||
}
|
||||
}
|
||||
|
||||
file.Options = &RepoFileOptions{
|
||||
treePath: treePath,
|
||||
fromTreePath: fromTreePath,
|
||||
executable: false,
|
||||
}
|
||||
treePaths = append(treePaths, treePath)
|
||||
}
|
||||
|
||||
// A NewBranch can be specified for the file to be created/updated in a new branch.
|
||||
// Check to make sure the branch does not already exist, otherwise we can't proceed.
|
||||
// If we aren't branching to a new branch, make sure user can commit to the given branch
|
||||
if opts.NewBranch != opts.OldBranch {
|
||||
exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.NewBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist {
|
||||
if !opts.ForcePush {
|
||||
// branch exists but force option not set
|
||||
return nil, git_model.ErrBranchAlreadyExists{
|
||||
BranchName: opts.NewBranch,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := VerifyBranchProtection(ctx, repo, gitRepo, doer, opts.OldBranch, treePaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(opts.Message)
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
}
|
||||
defer t.Close()
|
||||
hasOldBranch := true
|
||||
if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
|
||||
for _, file := range opts.Files {
|
||||
if file.Operation == "delete" {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !git.IsErrBranchNotExist(err) || !repo.IsEmpty {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.Init(ctx, repo.ObjectFormatName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasOldBranch = false
|
||||
opts.LastCommitID = ""
|
||||
}
|
||||
if hasOldBranch {
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if hasOldBranch {
|
||||
// Get the commit of the original branch
|
||||
commit, err := t.GetBranchCommit(opts.OldBranch)
|
||||
if err != nil {
|
||||
return nil, err // Couldn't get a commit for the branch
|
||||
}
|
||||
|
||||
// Assigned LastCommitID in "opts" if it hasn't been set
|
||||
if opts.LastCommitID == "" {
|
||||
opts.LastCommitID = commit.ID.String()
|
||||
} else {
|
||||
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %w", err)
|
||||
}
|
||||
opts.LastCommitID = lastCommitID.String()
|
||||
}
|
||||
|
||||
for _, file := range opts.Files {
|
||||
if err = handleCheckErrors(file, commit, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lfsContentStore := lfs.NewContentStore()
|
||||
for _, file := range opts.Files {
|
||||
switch file.Operation {
|
||||
case "create", "update", "rename", "upload":
|
||||
addedLfsPointer, err := modifyFile(ctx, t, file, lfsContentStore, repo.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if addedLfsPointer != nil {
|
||||
addedLfsPointers = append(addedLfsPointers, *addedLfsPointer)
|
||||
}
|
||||
case "delete":
|
||||
if file.DeleteRecursively {
|
||||
if err = t.RemoveRecursivelyFromIndex(ctx, file.TreePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Now write the tree
|
||||
treeHash, err := t.WriteTree(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now commit the tree
|
||||
commitOpts := &CommitTreeUserOptions{
|
||||
ParentCommitID: opts.LastCommitID,
|
||||
TreeHash: treeHash,
|
||||
CommitMessage: message,
|
||||
SignOff: opts.Signoff,
|
||||
DoerUser: doer,
|
||||
AuthorIdentity: opts.Author,
|
||||
AuthorTime: nil,
|
||||
CommitterIdentity: opts.Committer,
|
||||
CommitterTime: nil,
|
||||
}
|
||||
if opts.Dates != nil {
|
||||
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
|
||||
}
|
||||
commitHash, err := t.CommitTree(ctx, commitOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Then push this tree to NewBranch
|
||||
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, opts.ForcePush); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err := t.GetCommit(commitHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FIXME: this call seems not right, why it needs to read the file content again
|
||||
// FIXME: why it uses the NewBranch as "ref", it should use the commit ID because the response is only for this commit
|
||||
filesResponse, err := GetFilesResponseFromCommit(ctx, repo, gitRepo, utils.NewRefCommit(git.RefNameFromBranch(opts.NewBranch), commit), treePaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if repo.IsEmpty {
|
||||
if isEmpty, err := gitRepo.IsEmpty(); err == nil && !isEmpty {
|
||||
_ = repo_model.UpdateRepositoryColsWithAutoTime(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false, DefaultBranch: opts.NewBranch}, "is_empty", "default_branch")
|
||||
}
|
||||
}
|
||||
|
||||
return filesResponse, nil
|
||||
}
|
||||
|
||||
// ErrRepoFileAlreadyExists represents a "RepoFileAlreadyExist" kind of error.
|
||||
type ErrRepoFileAlreadyExists struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// IsErrRepoFileAlreadyExists checks if an error is a ErrRepoFileAlreadyExists.
|
||||
func IsErrRepoFileAlreadyExists(err error) bool {
|
||||
_, ok := err.(ErrRepoFileAlreadyExists)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoFileAlreadyExists) Error() string {
|
||||
return fmt.Sprintf("repository file already exists [path: %s]", err.Path)
|
||||
}
|
||||
|
||||
func (err ErrRepoFileAlreadyExists) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrFilePathInvalid represents a "FilePathInvalid" kind of error.
|
||||
type ErrFilePathInvalid struct {
|
||||
Message string
|
||||
Path string
|
||||
Name string
|
||||
Type git.EntryMode
|
||||
}
|
||||
|
||||
// IsErrFilePathInvalid checks if an error is an ErrFilePathInvalid.
|
||||
func IsErrFilePathInvalid(err error) bool {
|
||||
_, ok := err.(ErrFilePathInvalid)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrFilePathInvalid) Error() string {
|
||||
if err.Message != "" {
|
||||
return err.Message
|
||||
}
|
||||
return fmt.Sprintf("path is invalid [path: %s]", err.Path)
|
||||
}
|
||||
|
||||
func (err ErrFilePathInvalid) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ErrSHAOrCommitIDNotProvided represents a "SHAOrCommitIDNotProvided" kind of error.
|
||||
type ErrSHAOrCommitIDNotProvided struct{}
|
||||
|
||||
// IsErrSHAOrCommitIDNotProvided checks if an error is a ErrSHAOrCommitIDNotProvided.
|
||||
func IsErrSHAOrCommitIDNotProvided(err error) bool {
|
||||
_, ok := err.(ErrSHAOrCommitIDNotProvided)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrSHAOrCommitIDNotProvided) Error() string {
|
||||
return "a SHA or commit ID must be proved when updating a file"
|
||||
}
|
||||
|
||||
// handles the check for various issues for ChangeRepoFiles
|
||||
func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions) error {
|
||||
// check old entry (fromTreePath/fromEntry)
|
||||
if file.Operation == "update" || file.Operation == "upload" || file.Operation == "delete" || file.Operation == "rename" {
|
||||
var fromEntryIDString string
|
||||
{
|
||||
fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
|
||||
if file.Operation == "upload" && git.IsErrNotExist(err) {
|
||||
fromEntry = nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEntry != nil {
|
||||
fromEntryIDString = fromEntry.ID.String()
|
||||
file.Options.executable = fromEntry.IsExecutable() // FIXME: legacy hacky approach, it shouldn't prepare the "Options" in the "check" function
|
||||
}
|
||||
}
|
||||
|
||||
if file.SHA != "" {
|
||||
// If the SHA given doesn't match the SHA of the fromTreePath, throw error
|
||||
if file.SHA != fromEntryIDString {
|
||||
return pull_service.ErrSHADoesNotMatch{
|
||||
Path: file.Options.treePath,
|
||||
GivenSHA: file.SHA,
|
||||
CurrentSHA: fromEntryIDString,
|
||||
}
|
||||
}
|
||||
} else if opts.LastCommitID != "" {
|
||||
// If a lastCommitID given doesn't match the branch head's commitID throw
|
||||
// an error, but only if we aren't creating a new branch.
|
||||
if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch {
|
||||
if changed, err := commit.FileChangedSinceCommit(file.Options.treePath, opts.LastCommitID); err != nil {
|
||||
return err
|
||||
} else if changed {
|
||||
return ErrCommitIDDoesNotMatch{
|
||||
GivenCommitID: opts.LastCommitID,
|
||||
CurrentCommitID: opts.LastCommitID,
|
||||
}
|
||||
}
|
||||
// The file wasn't modified, so we are good to delete it
|
||||
}
|
||||
} else {
|
||||
// When updating a file, a lastCommitID or SHA needs to be given to make sure other commits
|
||||
// haven't been made. We throw an error if one wasn't provided.
|
||||
return ErrSHAOrCommitIDNotProvided{}
|
||||
}
|
||||
}
|
||||
|
||||
// check new entry (treePath/treeEntry)
|
||||
if file.Operation == "create" || file.Operation == "update" || file.Operation == "upload" || file.Operation == "rename" {
|
||||
// For operation's target path, we need to make sure no parts of the path are existing files or links
|
||||
// except for the last item in the path (which is the file name).
|
||||
// And that shouldn't exist IF it is a new file OR is being moved to a new path.
|
||||
treePathParts := strings.Split(file.Options.treePath, "/")
|
||||
subTreePath := ""
|
||||
for index, part := range treePathParts {
|
||||
subTreePath = path.Join(subTreePath, part)
|
||||
entry, err := commit.GetTreeEntryByPath(subTreePath)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
// Means there is no item with that name, so we're good
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
if index < len(treePathParts)-1 {
|
||||
if !entry.IsDir() {
|
||||
return ErrFilePathInvalid{
|
||||
Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
|
||||
Path: subTreePath,
|
||||
Name: part,
|
||||
Type: git.EntryModeBlob,
|
||||
}
|
||||
}
|
||||
} else if entry.IsLink() {
|
||||
return ErrFilePathInvalid{
|
||||
Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
|
||||
Path: subTreePath,
|
||||
Name: part,
|
||||
Type: git.EntryModeSymlink,
|
||||
}
|
||||
} else if entry.IsDir() {
|
||||
return ErrFilePathInvalid{
|
||||
Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath),
|
||||
Path: subTreePath,
|
||||
Name: part,
|
||||
Type: git.EntryModeTree,
|
||||
}
|
||||
} else if file.Options.fromTreePath != file.Options.treePath || file.Operation == "create" {
|
||||
// The entry shouldn't exist if we are creating the new file or moving to a new path
|
||||
return ErrRepoFileAlreadyExists{
|
||||
Path: file.Options.treePath,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func modifyFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64) (addedLfsPointer *lfs.Pointer, _ error) {
|
||||
if rd, ok := file.ContentReader.(LazyReadSeeker); ok {
|
||||
if err := rd.OpenLazyReader(); err != nil {
|
||||
return nil, fmt.Errorf("OpenLazyReader: %w", err)
|
||||
}
|
||||
defer rd.Close()
|
||||
}
|
||||
|
||||
// Get the two paths (might be the same if not moving) from the index if they exist
|
||||
filesInIndex, err := t.LsFiles(ctx, file.TreePath, file.FromTreePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LsFiles: %w", err)
|
||||
}
|
||||
// If is a new file (not updating) then the given path shouldn't exist
|
||||
if file.Operation == "create" {
|
||||
if slices.Contains(filesInIndex, file.TreePath) {
|
||||
return nil, ErrRepoFileAlreadyExists{Path: file.TreePath}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the old path from the tree
|
||||
if file.Options.fromTreePath != file.Options.treePath && len(filesInIndex) > 0 {
|
||||
for _, indexFile := range filesInIndex {
|
||||
if indexFile == file.Options.fromTreePath {
|
||||
if err = t.RemoveFilesFromIndex(ctx, file.FromTreePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var writeObjectRet *writeRepoObjectRet
|
||||
switch file.Operation {
|
||||
case "create", "update", "upload":
|
||||
writeObjectRet, err = writeRepoObjectForModify(ctx, t, file)
|
||||
case "rename":
|
||||
writeObjectRet, err = writeRepoObjectForRename(ctx, t, file)
|
||||
default:
|
||||
return nil, util.NewInvalidArgumentErrorf("unknown file modification operation: '%s'", file.Operation)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add the object to the index, the "file.Options.executable" is set in handleCheckErrors by the caller (legacy hacky approach)
|
||||
if err = t.AddObjectToIndex(ctx, util.Iif(file.Options.executable, "100755", "100644"), writeObjectRet.ObjectHash, file.Options.treePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if writeObjectRet.LfsContent == nil {
|
||||
return nil, nil //nolint:nilnil // No LFS pointer, so nothing to do
|
||||
}
|
||||
defer writeObjectRet.LfsContent.Close()
|
||||
|
||||
// Now we must store the content into an LFS object
|
||||
lfsMetaObject, err := git_model.NewLFSMetaObject(ctx, repoID, writeObjectRet.LfsPointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exist, err := contentStore.Exists(lfsMetaObject.Pointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
err = contentStore.Put(lfsMetaObject.Pointer, writeObjectRet.LfsContent)
|
||||
if err != nil {
|
||||
if _, errRemove := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); errRemove != nil {
|
||||
return nil, fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, errRemove, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &lfsMetaObject.Pointer, nil
|
||||
}
|
||||
|
||||
func checkIsLfsFileInGitAttributes(ctx context.Context, t *TemporaryUploadRepository, paths []string) (ret []bool, err error) {
|
||||
attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
|
||||
Attributes: []string{attribute.Filter},
|
||||
Filenames: paths,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range paths {
|
||||
isLFSFile := attributesMap[p] != nil && attributesMap[p].Get(attribute.Filter).ToString().Value() == "lfs"
|
||||
ret = append(ret, isLFSFile)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
type writeRepoObjectRet struct {
|
||||
ObjectHash string
|
||||
LfsContent io.ReadCloser // if not nil, then the caller should store its content in LfsPointer, then close it
|
||||
LfsPointer lfs.Pointer
|
||||
}
|
||||
|
||||
// writeRepoObjectForModify hashes the git object for create or update operations
|
||||
func writeRepoObjectForModify(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile) (ret *writeRepoObjectRet, err error) {
|
||||
ret = &writeRepoObjectRet{}
|
||||
treeObjectContentReader := file.ContentReader
|
||||
if setting.LFS.StartServer {
|
||||
checkIsLfsFiles, err := checkIsLfsFileInGitAttributes(ctx, t, []string{file.Options.treePath})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if checkIsLfsFiles[0] {
|
||||
// OK, so we are supposed to LFS this data!
|
||||
ret.LfsPointer, err = lfs.GeneratePointer(file.ContentReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = file.ContentReader.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.LfsContent = io.NopCloser(file.ContentReader)
|
||||
treeObjectContentReader = strings.NewReader(ret.LfsPointer.StringContent())
|
||||
}
|
||||
}
|
||||
|
||||
ret.ObjectHash, err = t.HashObjectAndWrite(ctx, treeObjectContentReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// writeRepoObjectForRename the same as writeRepoObjectForModify buf for "rename"
|
||||
func writeRepoObjectForRename(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile) (ret *writeRepoObjectRet, err error) {
|
||||
lastCommitID, err := t.GetLastCommit(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commit, err := t.GetCommit(lastCommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = &writeRepoObjectRet{ObjectHash: oldEntry.ID.String()}
|
||||
if !setting.LFS.StartServer {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
checkIsLfsFiles, err := checkIsLfsFileInGitAttributes(ctx, t, []string{file.Options.fromTreePath, file.Options.treePath})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldIsLfs, newIsLfs := checkIsLfsFiles[0], checkIsLfsFiles[1]
|
||||
|
||||
// If the old and new paths are both in lfs or both not in lfs, the object hash of the old file can be used directly
|
||||
// as the object doesn't change
|
||||
if oldIsLfs == newIsLfs {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
oldEntryBlobPointerBy := func(f func(r io.Reader) (lfs.Pointer, error)) (lfsPointer lfs.Pointer, err error) {
|
||||
r, err := oldEntry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
return lfsPointer, err
|
||||
}
|
||||
defer r.Close()
|
||||
return f(r)
|
||||
}
|
||||
|
||||
var treeObjectContentReader io.ReadCloser
|
||||
if oldIsLfs {
|
||||
// If the old is in lfs but the new isn't, read the content from lfs and add it as a normal git object
|
||||
pointer, err := oldEntryBlobPointerBy(lfs.ReadPointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
treeObjectContentReader, err = lfs.ReadMetaObject(pointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer treeObjectContentReader.Close()
|
||||
} else {
|
||||
// If the new is in lfs but the old isn't, read the content from the git object and generate a lfs pointer of it
|
||||
ret.LfsPointer, err = oldEntryBlobPointerBy(lfs.GeneratePointer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.LfsContent, err = oldEntry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
treeObjectContentReader = io.NopCloser(strings.NewReader(ret.LfsPointer.StringContent()))
|
||||
}
|
||||
ret.ObjectHash, err = t.HashObjectAndWrite(ctx, treeObjectContentReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
|
||||
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, branchName string, treePaths []string) error {
|
||||
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if protectedBranch != nil {
|
||||
protectedBranch.Repo = repo
|
||||
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
|
||||
globProtected := protectedBranch.GetProtectedFilePatterns()
|
||||
canUserPush := protectedBranch.CanUserPush(ctx, doer)
|
||||
for _, treePath := range treePaths {
|
||||
isUnprotectedFile := false
|
||||
if len(globUnprotected) != 0 {
|
||||
isUnprotectedFile = protectedBranch.IsUnprotectedFile(globUnprotected, treePath)
|
||||
}
|
||||
if !canUserPush && !isUnprotectedFile {
|
||||
return ErrUserCannotCommit{
|
||||
UserName: doer.LowerName,
|
||||
}
|
||||
}
|
||||
if protectedBranch.IsProtectedFile(globProtected, treePath) {
|
||||
return pull_service.ErrFilePathProtected{
|
||||
Path: treePath,
|
||||
}
|
||||
}
|
||||
}
|
||||
if protectedBranch.RequireSignedCommits {
|
||||
_, _, _, err := asymkey_service.SignCRUDAction(ctx, doer, gitRepo, branchName)
|
||||
if err != nil {
|
||||
if !asymkey_service.IsErrWontSign(err) {
|
||||
return err
|
||||
}
|
||||
return ErrUserCannotCommit{
|
||||
UserName: doer.LowerName,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// UploadRepoFileOptions contains the uploaded repository file options
|
||||
type UploadRepoFileOptions struct {
|
||||
LastCommitID string
|
||||
OldBranch string
|
||||
NewBranch string
|
||||
TreePath string
|
||||
Message string
|
||||
Files []string // In UUID format.
|
||||
Signoff bool
|
||||
Author *IdentityOptions
|
||||
Committer *IdentityOptions
|
||||
}
|
||||
|
||||
type lazyLocalFileReader struct {
|
||||
*os.File
|
||||
localFilename string
|
||||
counter int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ LazyReadSeeker = (*lazyLocalFileReader)(nil)
|
||||
|
||||
func (l *lazyLocalFileReader) Close() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.counter > 0 {
|
||||
l.counter--
|
||||
if l.counter == 0 {
|
||||
if err := l.File.Close(); err != nil {
|
||||
return fmt.Errorf("close file %s: %w", l.localFilename, err)
|
||||
}
|
||||
l.File = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("file %s already closed", l.localFilename)
|
||||
}
|
||||
|
||||
func (l *lazyLocalFileReader) OpenLazyReader() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.File != nil {
|
||||
l.counter++
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(l.localFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.File = file
|
||||
l.counter = 1
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadRepoFiles uploads files to the given repository
|
||||
func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *UploadRepoFileOptions) error {
|
||||
if len(opts.Files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
uploads, err := repo_model.GetUploadsByUUIDs(ctx, opts.Files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUploadsByUUIDs [uuids: %v]: %w", opts.Files, err)
|
||||
}
|
||||
|
||||
changeOpts := &ChangeRepoFilesOptions{
|
||||
LastCommitID: opts.LastCommitID,
|
||||
OldBranch: opts.OldBranch,
|
||||
NewBranch: opts.NewBranch,
|
||||
Message: opts.Message,
|
||||
Signoff: opts.Signoff,
|
||||
Author: opts.Author,
|
||||
Committer: opts.Committer,
|
||||
}
|
||||
for _, upload := range uploads {
|
||||
changeOpts.Files = append(changeOpts.Files, &ChangeRepoFile{
|
||||
Operation: "upload",
|
||||
TreePath: path.Join(opts.TreePath, upload.Name),
|
||||
ContentReader: &lazyLocalFileReader{localFilename: upload.LocalPath()},
|
||||
})
|
||||
}
|
||||
|
||||
_, err = ChangeRepoFiles(ctx, repo, doer, changeOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo_model.DeleteUploads(ctx, uploads...); err != nil {
|
||||
log.Error("DeleteUploads: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error.
|
||||
type ErrForkAlreadyExist struct {
|
||||
Uname string
|
||||
RepoName string
|
||||
ForkName string
|
||||
}
|
||||
|
||||
// IsErrForkAlreadyExist checks if an error is an ErrForkAlreadyExist.
|
||||
func IsErrForkAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrForkAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrForkAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("repository is already forked by user [uname: %s, repo path: %s, fork path: %s]", err.Uname, err.RepoName, err.ForkName)
|
||||
}
|
||||
|
||||
func (err ErrForkAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ForkRepoOptions contains the fork repository options
|
||||
type ForkRepoOptions struct {
|
||||
BaseRepo *repo_model.Repository
|
||||
Name string
|
||||
Description string
|
||||
SingleBranch string
|
||||
}
|
||||
|
||||
// ForkRepository forks a repository
|
||||
func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts ForkRepoOptions) (*repo_model.Repository, error) {
|
||||
if err := opts.BaseRepo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, opts.BaseRepo.Owner.ID) {
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
// Fork is prohibited, if user has reached maximum limit of repositories
|
||||
if !doer.CanForkRepoIn(owner) {
|
||||
return nil, repo_model.ErrReachLimitOfRepo{
|
||||
Limit: owner.MaxRepoCreation,
|
||||
}
|
||||
}
|
||||
|
||||
forkedRepo, err := repo_model.GetUserFork(ctx, opts.BaseRepo.ID, owner.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if forkedRepo != nil {
|
||||
return nil, ErrForkAlreadyExist{
|
||||
Uname: owner.Name,
|
||||
RepoName: opts.BaseRepo.FullName(),
|
||||
ForkName: forkedRepo.FullName(),
|
||||
}
|
||||
}
|
||||
|
||||
defaultBranch := opts.BaseRepo.DefaultBranch
|
||||
if opts.SingleBranch != "" {
|
||||
defaultBranch = opts.SingleBranch
|
||||
}
|
||||
repo := &repo_model.Repository{
|
||||
OwnerID: owner.ID,
|
||||
Owner: owner,
|
||||
OwnerName: owner.Name,
|
||||
Name: opts.Name,
|
||||
LowerName: strings.ToLower(opts.Name),
|
||||
Description: opts.Description,
|
||||
DefaultBranch: defaultBranch,
|
||||
IsPrivate: opts.BaseRepo.IsPrivate || opts.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate,
|
||||
IsEmpty: opts.BaseRepo.IsEmpty,
|
||||
IsFork: true,
|
||||
ForkID: opts.BaseRepo.ID,
|
||||
ObjectFormatName: opts.BaseRepo.ObjectFormatName,
|
||||
Status: repo_model.RepositoryBeingMigrated,
|
||||
}
|
||||
|
||||
// 1 - Create the repository in the database
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
|
||||
// copy lfs files failure should not be ignored
|
||||
return git_model.CopyLFS(ctx, repo, opts.BaseRepo)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// last - clean up if something goes wrong
|
||||
// WARNING: Don't override all later err with local variables
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// we can not use `ctx` because it may be canceled or timed out
|
||||
cleanupRepository(repo)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2 - check whether the repository with the same storage exists
|
||||
var isExist bool
|
||||
isExist, err = gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repo.FullName(), err)
|
||||
return nil, err
|
||||
}
|
||||
if isExist {
|
||||
log.Error("Files already exist in %s and we are not going to adopt or delete.", repo.FullName())
|
||||
// Don't return directly, we need err in defer to cleanupRepository
|
||||
err = repo_model.ErrRepoFilesAlreadyExist{
|
||||
Uname: repo.OwnerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3 - Clone the repository
|
||||
cloneOpts := git.CloneRepoOptions{
|
||||
Bare: true,
|
||||
Timeout: 10 * time.Minute,
|
||||
}
|
||||
if opts.SingleBranch != "" {
|
||||
cloneOpts.SingleBranch = true
|
||||
cloneOpts.Branch = opts.SingleBranch
|
||||
}
|
||||
if err = gitrepo.Clone(ctx, opts.BaseRepo, repo, cloneOpts); err != nil {
|
||||
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nError: %v", repo, opts.BaseRepo, err)
|
||||
return nil, fmt.Errorf("git clone: %w", err)
|
||||
}
|
||||
|
||||
// 4 - Update the git repository
|
||||
if err = updateGitRepoAfterCreate(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("updateGitRepoAfterCreate: %w", err)
|
||||
}
|
||||
|
||||
// 5 - Create hooks
|
||||
if err = gitrepo.CreateDelegateHooks(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("createDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
// 6 - Sync the repository branches and tags
|
||||
var gitRepo *git.Repository
|
||||
gitRepo, err = gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
if _, _, err = repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, doer.ID); err != nil {
|
||||
return nil, fmt.Errorf("SyncRepoBranchesWithRepo: %w", err)
|
||||
}
|
||||
if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
|
||||
return nil, fmt.Errorf("Sync releases from git tags failed: %v", err)
|
||||
}
|
||||
|
||||
// 7 - Update the repository
|
||||
// even if below operations failed, it could be ignored. And they will be retried
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
err = nil
|
||||
}
|
||||
if err = repo_model.CopyLanguageStat(ctx, opts.BaseRepo, repo); err != nil {
|
||||
log.Error("Copy language stat from oldRepo failed: %v", err)
|
||||
err = nil
|
||||
}
|
||||
if err = repo_model.CopyLicense(ctx, opts.BaseRepo, repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8 - update repository status to be ready
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err = repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "status"); err != nil {
|
||||
return nil, fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
|
||||
notify_service.ForkRepository(ctx, doer, opts.BaseRepo, repo)
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// ConvertForkToNormalRepository convert the provided repo from a forked repo to normal repo
|
||||
func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Repository) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !repo.IsFork {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := repo_model.DecrementRepoForkNum(ctx, repo.ForkID); err != nil {
|
||||
log.Error("Unable to decrement repo fork num for old root repo %d of repository %-v whilst converting from fork. Error: %v", repo.ForkID, repo, err)
|
||||
return err
|
||||
}
|
||||
|
||||
repo.IsFork = false
|
||||
repo.ForkID = 0
|
||||
return repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_fork", "fork_id")
|
||||
})
|
||||
}
|
||||
|
||||
type findForksOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
Doer *user_model.User
|
||||
}
|
||||
|
||||
func (opts findForksOptions) ToConds() builder.Cond {
|
||||
cond := builder.Eq{"fork_id": opts.RepoID}
|
||||
if opts.Doer != nil && opts.Doer.IsAdmin {
|
||||
return cond
|
||||
}
|
||||
return cond.And(repo_model.AccessibleRepositoryCondition(opts.Doer, unit.TypeInvalid))
|
||||
}
|
||||
|
||||
// FindForks returns all the forks of the repository
|
||||
func FindForks(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, listOptions db.ListOptions) ([]*repo_model.Repository, int64, error) {
|
||||
return db.FindAndCount[repo_model.Repository](ctx, findForksOptions{
|
||||
ListOptions: listOptions,
|
||||
RepoID: repo.ID,
|
||||
Doer: doer,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestForkRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user 13 has already forked repo10
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
|
||||
fork, err := ForkRepository(t.Context(), user, user, ForkRepoOptions{
|
||||
BaseRepo: repo,
|
||||
Name: "test",
|
||||
Description: "test",
|
||||
})
|
||||
assert.Nil(t, fork)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrForkAlreadyExist(err))
|
||||
|
||||
// user not reached maximum limit of repositories
|
||||
assert.False(t, repo_model.IsErrReachLimitOfRepo(err))
|
||||
|
||||
// change AllowForkWithoutMaximumLimit to false for the test
|
||||
defer test.MockVariableValue(&setting.Repository.AllowForkWithoutMaximumLimit, false)()
|
||||
// user has reached maximum limit of repositories
|
||||
user.MaxRepoCreation = 0
|
||||
fork2, err := ForkRepository(t.Context(), user, user, ForkRepoOptions{
|
||||
BaseRepo: repo,
|
||||
Name: "test",
|
||||
Description: "test",
|
||||
})
|
||||
assert.Nil(t, fork2)
|
||||
assert.True(t, repo_model.IsErrReachLimitOfRepo(err))
|
||||
}
|
||||
|
||||
func TestForkRepositoryCleanup(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// a successful fork
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
|
||||
fork, err := ForkRepository(t.Context(), user2, user2, ForkRepoOptions{
|
||||
BaseRepo: repo10,
|
||||
Name: "test",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fork)
|
||||
|
||||
exist, err := util.IsExist(repo_model.RepoPath(user2.Name, "test"))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
|
||||
err = DeleteRepositoryDirectly(t.Context(), fork.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// a failed creating because some mock data
|
||||
// create the repository directory so that the creation will fail after database record created.
|
||||
assert.NoError(t, os.MkdirAll(repo_model.RepoPath(user2.Name, "test"), os.ModePerm))
|
||||
|
||||
fork2, err := ForkRepository(t.Context(), user2, user2, ForkRepoOptions{
|
||||
BaseRepo: repo10,
|
||||
Name: "test",
|
||||
})
|
||||
assert.Nil(t, fork2)
|
||||
assert.Error(t, err)
|
||||
|
||||
// assert the cleanup is successful
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerName: user2.Name, Name: "test"})
|
||||
|
||||
exist, err = util.IsExist(repo_model.RepoPath(user2.Name, "test"))
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exist)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/huandu/xstrings"
|
||||
)
|
||||
|
||||
type transformer struct {
|
||||
Name string
|
||||
Transform func(string) string
|
||||
}
|
||||
|
||||
type expansion struct {
|
||||
Name string
|
||||
Value string
|
||||
Transformers []transformer
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
defaultTransformers []transformer
|
||||
fileNameSanitizeRegexp *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
ret.defaultTransformers = []transformer{
|
||||
{Name: "SNAKE", Transform: xstrings.ToSnakeCase},
|
||||
{Name: "KEBAB", Transform: xstrings.ToKebabCase},
|
||||
{Name: "CAMEL", Transform: xstrings.ToCamelCase},
|
||||
{Name: "PASCAL", Transform: xstrings.ToPascalCase},
|
||||
{Name: "LOWER", Transform: strings.ToLower},
|
||||
{Name: "UPPER", Transform: strings.ToUpper},
|
||||
{Name: "TITLE", Transform: util.ToTitleCase},
|
||||
}
|
||||
|
||||
// invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex
|
||||
// "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid
|
||||
ret.fileNameSanitizeRegexp = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
|
||||
return ret
|
||||
})
|
||||
|
||||
func generateExpansion(ctx context.Context, src string, templateRepo, generateRepo *repo_model.Repository) string {
|
||||
transformers := globalVars().defaultTransformers
|
||||
year, month, day := time.Now().Date()
|
||||
expansions := []expansion{
|
||||
{Name: "YEAR", Value: strconv.Itoa(year), Transformers: nil},
|
||||
{Name: "MONTH", Value: fmt.Sprintf("%02d", int(month)), Transformers: nil},
|
||||
{Name: "MONTH_ENGLISH", Value: month.String(), Transformers: transformers},
|
||||
{Name: "DAY", Value: fmt.Sprintf("%02d", day), Transformers: nil},
|
||||
{Name: "REPO_NAME", Value: generateRepo.Name, Transformers: transformers},
|
||||
{Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: transformers},
|
||||
{Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil},
|
||||
{Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil},
|
||||
{Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: transformers},
|
||||
{Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: transformers},
|
||||
{Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil},
|
||||
{Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil},
|
||||
{Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLinkGeneral(ctx).HTTPS, Transformers: nil},
|
||||
{Name: "TEMPLATE_HTTPS_URL", Value: templateRepo.CloneLinkGeneral(ctx).HTTPS, Transformers: nil},
|
||||
{Name: "REPO_SSH_URL", Value: generateRepo.CloneLinkGeneral(ctx).SSH, Transformers: nil},
|
||||
{Name: "TEMPLATE_SSH_URL", Value: templateRepo.CloneLinkGeneral(ctx).SSH, Transformers: nil},
|
||||
}
|
||||
|
||||
expansionMap := make(map[string]string)
|
||||
for _, e := range expansions {
|
||||
expansionMap[e.Name] = e.Value
|
||||
for _, tr := range e.Transformers {
|
||||
expansionMap[fmt.Sprintf("%s_%s", e.Name, tr.Name)] = tr.Transform(e.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return os.Expand(src, func(key string) string {
|
||||
if val, ok := expansionMap[key]; ok {
|
||||
return val
|
||||
}
|
||||
return key
|
||||
})
|
||||
}
|
||||
|
||||
// giteaTemplateFileMatcher holds information about a .gitea/template file
|
||||
type giteaTemplateFileMatcher struct {
|
||||
relPath string
|
||||
globs []glob.Glob
|
||||
}
|
||||
|
||||
func newGiteaTemplateFileMatcher(relPath string, content []byte) *giteaTemplateFileMatcher {
|
||||
gt := &giteaTemplateFileMatcher{relPath: relPath}
|
||||
gt.globs = make([]glob.Glob, 0)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
g, err := glob.Compile(line, '/')
|
||||
if err != nil {
|
||||
log.Debug("Invalid glob expression '%s' (skipped): %v", line, err)
|
||||
continue
|
||||
}
|
||||
gt.globs = append(gt.globs, g)
|
||||
}
|
||||
return gt
|
||||
}
|
||||
|
||||
func (gt *giteaTemplateFileMatcher) HasRules() bool {
|
||||
return len(gt.globs) != 0
|
||||
}
|
||||
|
||||
func (gt *giteaTemplateFileMatcher) Match(s string) bool {
|
||||
for _, g := range gt.globs {
|
||||
if g.Match(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readGiteaTemplateFile(tmpDir string) (*giteaTemplateFileMatcher, error) {
|
||||
templateRelPath := filepath.Join(".gitea", "template")
|
||||
content, err := util.ReadRegularPathFile(tmpDir, templateRelPath, 1024*1024)
|
||||
if err != nil {
|
||||
return nil, util.Iif(errors.Is(err, util.ErrNotRegularPathFile), os.ErrNotExist, err)
|
||||
}
|
||||
return newGiteaTemplateFileMatcher(templateRelPath, content), nil
|
||||
}
|
||||
|
||||
func substGiteaTemplateFile(ctx context.Context, tmpDir, tmpDirSubPath string, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
content, err := util.ReadRegularPathFile(tmpDir, tmpDirSubPath, 1024*1024)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(util.FilePathJoinAbs(tmpDir, tmpDirSubPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generatedContent := generateExpansion(ctx, string(content), templateRepo, generateRepo)
|
||||
substSubPath := filePathSanitize(generateExpansion(ctx, tmpDirSubPath, templateRepo, generateRepo))
|
||||
return util.WriteRegularPathFile(tmpDir, substSubPath, []byte(generatedContent), 0o755, 0o644)
|
||||
}
|
||||
|
||||
// processGiteaTemplateFile processes and removes the .gitea/template file, does variable expansion for template files
|
||||
// and save the processed files to the filesystem. It returns a list of skipped files that are not regular paths.
|
||||
func processGiteaTemplateFile(ctx context.Context, tmpDir string, templateRepo, generateRepo *repo_model.Repository, fileMatcher *giteaTemplateFileMatcher) (skippedFiles []string, _ error) {
|
||||
// Why not use "os.Root" here: symlink is unsafe even in the same root but "os.Root" can't help, it's more difficult to use "os.Root" to do the WalkDir.
|
||||
if err := os.Remove(util.FilePathJoinAbs(tmpDir, fileMatcher.relPath)); err != nil {
|
||||
return nil, fmt.Errorf("unable to remove .gitea/template: %w", err)
|
||||
}
|
||||
if !fileMatcher.HasRules() {
|
||||
return skippedFiles, nil // Avoid walking tree if there are no globs
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(tmpDir, func(fullPath string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
tmpDirSubPath, err := filepath.Rel(tmpDir, fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileMatcher.Match(filepath.ToSlash(tmpDirSubPath)) {
|
||||
err := substGiteaTemplateFile(ctx, tmpDir, tmpDirSubPath, templateRepo, generateRepo)
|
||||
if errors.Is(err, util.ErrNotRegularPathFile) {
|
||||
skippedFiles = append(skippedFiles, tmpDirSubPath)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}) // end: WalkDir
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = util.RemoveAll(util.FilePathJoinAbs(tmpDir, ".git")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skippedFiles, nil
|
||||
}
|
||||
|
||||
func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository, tmpDir string) error {
|
||||
// set default branch based on whether it's specified in the newly generated repo or not
|
||||
repo.DefaultBranch = util.IfZero(repo.DefaultBranch, util.IfZero(templateRepo.DefaultBranch, setting.Repository.DefaultBranch))
|
||||
|
||||
// Clone to temporary path and do the init commit.
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, templateRepo, tmpDir, git.CloneRepoOptions{
|
||||
Depth: 1,
|
||||
Branch: templateRepo.DefaultBranch,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("git clone: %w", err)
|
||||
}
|
||||
|
||||
// Get active submodules from the template
|
||||
submodules, err := git.GetTemplateSubmoduleCommits(ctx, tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTemplateSubmoduleCommits: %w", err)
|
||||
}
|
||||
|
||||
if err = util.RemoveAll(filepath.Join(tmpDir, ".git")); err != nil {
|
||||
return fmt.Errorf("remove git dir: %w", err)
|
||||
}
|
||||
|
||||
// Variable expansion
|
||||
fileMatcher, err := readGiteaTemplateFile(tmpDir)
|
||||
if err == nil {
|
||||
_, err = processGiteaTemplateFile(ctx, tmpDir, templateRepo, generateRepo, fileMatcher)
|
||||
if err != nil {
|
||||
return fmt.Errorf("processGiteaTemplateFile: %w", err)
|
||||
}
|
||||
} else if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Debug("skip processing repo template files: no available .gitea/template")
|
||||
} else {
|
||||
return fmt.Errorf("readGiteaTemplateFile: %w", err)
|
||||
}
|
||||
|
||||
if err = git.InitRepository(ctx, tmpDir, false, templateRepo.ObjectFormatName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = git.AddTemplateSubmoduleIndexes(ctx, tmpDir, submodules); err != nil {
|
||||
return fmt.Errorf("failed to add submodules: %v", err)
|
||||
}
|
||||
|
||||
return initRepoCommit(ctx, tmpDir, repo, repo.Owner)
|
||||
}
|
||||
|
||||
// GenerateGitContent generates git content from a template repository
|
||||
func GenerateGitContent(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) (err error) {
|
||||
tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-" + generateRepo.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp dir for repository %s: %w", generateRepo.FullName(), err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err = generateRepoCommit(ctx, generateRepo, templateRepo, generateRepo, tmpDir); err != nil {
|
||||
return fmt.Errorf("generateRepoCommit: %w", err)
|
||||
}
|
||||
if err = gitrepo.SetDefaultBranch(ctx, generateRepo, generateRepo.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("setDefaultBranch: %w", err)
|
||||
}
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, generateRepo, "default_branch"); err != nil {
|
||||
return fmt.Errorf("updateRepository: %w", err)
|
||||
}
|
||||
|
||||
if err := repo_module.UpdateRepoSize(ctx, generateRepo); err != nil {
|
||||
return fmt.Errorf("failed to update size for repository: %w", err)
|
||||
}
|
||||
|
||||
if err := git_model.CopyLFS(ctx, generateRepo, templateRepo); err != nil {
|
||||
return fmt.Errorf("failed to copy LFS: %w", err)
|
||||
}
|
||||
|
||||
if _, err := repo_module.SyncRepoBranches(ctx, generateRepo.ID, 0); err != nil {
|
||||
return fmt.Errorf("SyncRepoBranches: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRepoOptions contains the template units to generate
|
||||
type GenerateRepoOptions struct {
|
||||
Name string
|
||||
DefaultBranch string
|
||||
Description string
|
||||
Private bool
|
||||
GitContent bool
|
||||
Topics bool
|
||||
GitHooks bool
|
||||
Webhooks bool
|
||||
Avatar bool
|
||||
IssueLabels bool
|
||||
ProtectedBranch bool
|
||||
}
|
||||
|
||||
// IsValid checks whether at least one option is chosen for generation
|
||||
func (gro GenerateRepoOptions) IsValid() bool {
|
||||
return gro.GitContent || gro.Topics || gro.GitHooks || gro.Webhooks || gro.Avatar ||
|
||||
gro.IssueLabels || gro.ProtectedBranch // or other items as they are added
|
||||
}
|
||||
|
||||
func filePathSanitize(s string) string {
|
||||
fields := strings.Split(filepath.ToSlash(s), "/")
|
||||
for i, field := range fields {
|
||||
field = strings.TrimSpace(strings.TrimSpace(globalVars().fileNameSanitizeRegexp.ReplaceAllString(field, "_")))
|
||||
if strings.HasPrefix(field, "..") {
|
||||
field = "__" + field[2:]
|
||||
}
|
||||
if strings.EqualFold(field, ".git") {
|
||||
field = "_" + field[1:]
|
||||
}
|
||||
fields[i] = field
|
||||
}
|
||||
return filepath.Clean(filepath.FromSlash(strings.Trim(strings.Join(fields, "/"), "/")))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGiteaTemplate(t *testing.T) {
|
||||
giteaTemplate := []byte(`
|
||||
# Header
|
||||
|
||||
# All .go files
|
||||
**.go
|
||||
|
||||
# All text files in /text/
|
||||
text/*.txt
|
||||
|
||||
# All files in modules folders
|
||||
**/modules/*
|
||||
`)
|
||||
|
||||
gt := newGiteaTemplateFileMatcher("", giteaTemplate)
|
||||
assert.Len(t, gt.globs, 3)
|
||||
|
||||
tt := []struct {
|
||||
Path string
|
||||
Match bool
|
||||
}{
|
||||
{Path: "main.go", Match: true},
|
||||
{Path: "sub/sub/foo.go", Match: true},
|
||||
|
||||
{Path: "a.txt", Match: false},
|
||||
{Path: "text/a.txt", Match: true},
|
||||
{Path: "sub/text/a.txt", Match: false},
|
||||
{Path: "text/a.json", Match: false},
|
||||
|
||||
{Path: "a/b/c/modules/README.md", Match: true},
|
||||
{Path: "a/b/c/modules/d/README.md", Match: false},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
assert.Equal(t, tc.Match, gt.Match(tc.Path), "path: %s", tc.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePathSanitize(t *testing.T) {
|
||||
// path clean
|
||||
assert.Equal(t, "a", filePathSanitize("//a/"))
|
||||
assert.Equal(t, "_a", filePathSanitize(`\a`))
|
||||
assert.Equal(t, "__/a/__", filePathSanitize(".. /a/ .."))
|
||||
assert.Equal(t, "__/a/_git/b_", filePathSanitize("./../a/.git/ b: "))
|
||||
|
||||
// Windows reserved names
|
||||
assert.Equal(t, "_", filePathSanitize("CoN"))
|
||||
assert.Equal(t, "_", filePathSanitize("LpT1"))
|
||||
assert.Equal(t, "_", filePathSanitize("CoM1"))
|
||||
assert.Equal(t, "test_CON", filePathSanitize("test_CON"))
|
||||
assert.Equal(t, "test CON", filePathSanitize("test CON "))
|
||||
|
||||
// special chars
|
||||
assert.Equal(t, "_", filePathSanitize("\u0000"))
|
||||
assert.Equal(t, ".", filePathSanitize(""))
|
||||
assert.Equal(t, ".", filePathSanitize("."))
|
||||
assert.Equal(t, ".", filePathSanitize("/"))
|
||||
}
|
||||
|
||||
func TestProcessGiteaTemplateFileGenerate(t *testing.T) {
|
||||
tmpDir := filepath.Join(t.TempDir(), "gitea-template-test")
|
||||
|
||||
assertFileContent := func(path, expected string) {
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, path))
|
||||
if expected == "" {
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, string(data), "file content mismatch for %s", path)
|
||||
}
|
||||
|
||||
assertSymLink := func(path, expected string) {
|
||||
link, err := os.Readlink(filepath.Join(tmpDir, path))
|
||||
if expected == "" {
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, link, "symlink target mismatch for %s", path)
|
||||
}
|
||||
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/.git", 0o755))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/.git/config", []byte("git-config-dummy"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/.gitea", 0o755))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/.gitea/template", []byte("*\ninclude/**"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/sub", 0o755))
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/include/foo/bar", 0o755))
|
||||
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/sub/link-target", []byte("link target content from ${TEMPLATE_NAME}"), 0o644))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/include/foo/bar/test.txt", []byte("include subdir ${TEMPLATE_NAME}"), 0o644))
|
||||
|
||||
// case-1
|
||||
{
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/normal", []byte("normal content"), 0o644))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/template", []byte("template from ${TEMPLATE_NAME}"), 0o644))
|
||||
}
|
||||
|
||||
// case-2
|
||||
{
|
||||
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/link"))
|
||||
}
|
||||
|
||||
// case-3
|
||||
{
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/subst-${REPO_NAME}", []byte("dummy subst repo name"), 0o644))
|
||||
}
|
||||
|
||||
// case-4
|
||||
assertSubstTemplateName := func(normalContent, toLinkContent, fromLinkContent string) {
|
||||
assertFileContent("subst-${TEMPLATE_NAME}-normal", normalContent)
|
||||
assertFileContent("subst-${TEMPLATE_NAME}-to-link", toLinkContent)
|
||||
assertFileContent("subst-${TEMPLATE_NAME}-from-link", fromLinkContent)
|
||||
}
|
||||
|
||||
// case-5
|
||||
{
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/real-dir", 0o755))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/real-dir/real-file", []byte("origin content"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(tmpDir+"/include/subst-${TEMPLATE_NAME}-link-dir", 0o755))
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/include/subst-${TEMPLATE_NAME}-link-dir/real-file", []byte("template content"), 0o644))
|
||||
require.NoError(t, os.Symlink(tmpDir+"/real-dir", tmpDir+"/include/subst-TemplateRepoName-link-dir"))
|
||||
}
|
||||
|
||||
{
|
||||
// will succeed
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-normal", []byte("dummy subst template name normal"), 0o644))
|
||||
// will be skipped if the path subst result is a link
|
||||
require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-to-link", []byte("dummy subst template name to link"), 0o644))
|
||||
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-TemplateRepoName-to-link"))
|
||||
// will be skipped since the source is a symlink
|
||||
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-${TEMPLATE_NAME}-from-link"))
|
||||
// pre-check
|
||||
assertSubstTemplateName("dummy subst template name normal", "dummy subst template name to link", "link target content from ${TEMPLATE_NAME}")
|
||||
}
|
||||
|
||||
// process the template files
|
||||
{
|
||||
templateRepo := &repo_model.Repository{Name: "TemplateRepoName"}
|
||||
generatedRepo := &repo_model.Repository{Name: "/../.gIt/name"}
|
||||
assertFileContent(".git/config", "git-config-dummy")
|
||||
fileMatcher, _ := readGiteaTemplateFile(tmpDir)
|
||||
skippedFiles, err := processGiteaTemplateFile(t.Context(), tmpDir, templateRepo, generatedRepo, fileMatcher)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"include/subst-${TEMPLATE_NAME}-link-dir/real-file",
|
||||
"include/subst-TemplateRepoName-link-dir",
|
||||
"link",
|
||||
"subst-${TEMPLATE_NAME}-from-link",
|
||||
"subst-${TEMPLATE_NAME}-to-link",
|
||||
"subst-TemplateRepoName-to-link",
|
||||
}, skippedFiles)
|
||||
assertFileContent(".git/config", "")
|
||||
assertFileContent(".gitea/template", "")
|
||||
assertFileContent("include/foo/bar/test.txt", "include subdir TemplateRepoName")
|
||||
}
|
||||
|
||||
// the lin target should never be modified, and since it is in a subdirectory, it is not affected by the template either
|
||||
assertFileContent("sub/link-target", "link target content from ${TEMPLATE_NAME}")
|
||||
|
||||
// case-1
|
||||
{
|
||||
assertFileContent("no-such", "")
|
||||
assertFileContent("normal", "normal content")
|
||||
assertFileContent("template", "template from TemplateRepoName")
|
||||
}
|
||||
|
||||
// case-2
|
||||
{
|
||||
// symlink with templates should be preserved (not read or write)
|
||||
assertSymLink("link", tmpDir+"/sub/link-target")
|
||||
}
|
||||
|
||||
// case-3
|
||||
{
|
||||
assertFileContent("subst-${REPO_NAME}", "")
|
||||
assertFileContent("subst-/__/_gIt/name", "dummy subst repo name")
|
||||
}
|
||||
|
||||
// case-4
|
||||
{
|
||||
// the paths with templates should have been removed, subst to a regular file, succeed, the link is preserved
|
||||
assertSubstTemplateName("", "", "link target content from ${TEMPLATE_NAME}")
|
||||
assertFileContent("subst-TemplateRepoName-normal", "dummy subst template name normal")
|
||||
// subst to a link, skip, and the target is unchanged
|
||||
assertSymLink("subst-TemplateRepoName-to-link", tmpDir+"/sub/link-target")
|
||||
// subst from a link, skip, and the target is unchanged
|
||||
assertSymLink("subst-${TEMPLATE_NAME}-from-link", tmpDir+"/sub/link-target")
|
||||
}
|
||||
|
||||
// case-5
|
||||
{
|
||||
assertFileContent("real-dir/real-file", "origin content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGiteaTemplateFileRead(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.Mkdir(tmpDir+"/.gitea", 0o755)
|
||||
templateFilePath := tmpDir + "/.gitea/template"
|
||||
_ = os.Remove(templateFilePath)
|
||||
_, err := os.Lstat(templateFilePath)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
_, err = readGiteaTemplateFile(tmpDir) // no template file
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
_ = os.WriteFile(templateFilePath+".target", []byte("test-data-target"), 0o644)
|
||||
_ = os.Symlink(templateFilePath+".target", templateFilePath)
|
||||
content, _ := os.ReadFile(templateFilePath)
|
||||
require.Equal(t, "test-data-target", string(content))
|
||||
_, err = readGiteaTemplateFile(tmpDir) // symlinked template file
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
_ = os.Remove(templateFilePath)
|
||||
_ = os.WriteFile(templateFilePath, []byte("test-data-regular"), 0o644)
|
||||
content, _ = os.ReadFile(templateFilePath)
|
||||
require.Equal(t, "test-data-regular", string(content))
|
||||
fm, err := readGiteaTemplateFile(tmpDir) // regular template file
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, fm.globs, 1)
|
||||
}
|
||||
|
||||
func TestTransformers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"SNAKE", "abc_def_xyz"},
|
||||
{"KEBAB", "abc-def-xyz"},
|
||||
{"CAMEL", "abcDefXyz"},
|
||||
{"PASCAL", "AbcDefXyz"},
|
||||
{"LOWER", "abc_def-xyz"},
|
||||
{"UPPER", "ABC_DEF-XYZ"},
|
||||
{"TITLE", "Abc_def-Xyz"},
|
||||
}
|
||||
|
||||
input := "Abc_Def-XYZ"
|
||||
assert.Len(t, globalVars().defaultTransformers, len(cases))
|
||||
for i, c := range cases {
|
||||
tf := globalVars().defaultTransformers[i]
|
||||
require.Equal(t, c.name, tf.Name)
|
||||
assert.Equal(t, c.expected, tf.Transform(input), "case %s", c.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitgraph
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// GetCommitGraph return a list of commit (GraphItems) from all branches
|
||||
func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bool, branches, files []string) (*Graph, error) {
|
||||
format := "DATA:%D|%H|%ad|%h|%s"
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full")
|
||||
|
||||
if hidePRRefs {
|
||||
graphCmd.AddArguments("--exclude=" + git.PullPrefix + "*")
|
||||
}
|
||||
|
||||
if len(branches) == 0 {
|
||||
graphCmd.AddArguments("--tags", "--branches")
|
||||
}
|
||||
|
||||
graphCmd.AddArguments("-C", "-M", "--date=iso-strict").
|
||||
AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
|
||||
AddOptionFormat("--pretty=format:%s", format)
|
||||
|
||||
if len(branches) > 0 {
|
||||
graphCmd.AddDynamicArguments(branches...)
|
||||
}
|
||||
if len(files) > 0 {
|
||||
graphCmd.AddDashesAndList(files...)
|
||||
}
|
||||
graph := NewGraph()
|
||||
|
||||
commitsToSkip := setting.UI.GraphMaxCommitNum * (page - 1)
|
||||
|
||||
stdoutReader, stdoutReaderClose := graphCmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
if err := graphCmd.
|
||||
WithDir(r.Path).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
scanner := bufio.NewScanner(stdoutReader)
|
||||
parser := &Parser{}
|
||||
parser.firstInUse = -1
|
||||
parser.maxAllowedColors = maxAllowedColors
|
||||
if maxAllowedColors > 0 {
|
||||
parser.availableColors = make([]int, maxAllowedColors)
|
||||
for i := range parser.availableColors {
|
||||
parser.availableColors[i] = i + 1
|
||||
}
|
||||
} else {
|
||||
parser.availableColors = []int{1, 2}
|
||||
}
|
||||
for commitsToSkip > 0 && scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
dataIdx := bytes.Index(line, []byte("DATA:"))
|
||||
if dataIdx < 0 {
|
||||
dataIdx = len(line)
|
||||
}
|
||||
starIdx := bytes.IndexByte(line, '*')
|
||||
if starIdx >= 0 && starIdx < dataIdx {
|
||||
commitsToSkip--
|
||||
}
|
||||
parser.ParseGlyphs(line[:dataIdx])
|
||||
}
|
||||
|
||||
row := 0
|
||||
|
||||
// Skip initial non-commit lines
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if bytes.IndexByte(line, '*') >= 0 {
|
||||
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||
return ctx.CancelPipeline(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
parser.ParseGlyphs(line)
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
row++
|
||||
line := scanner.Bytes()
|
||||
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||
return ctx.CancelPipeline(err)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}).
|
||||
RunWithStderr(r.Ctx); err != nil {
|
||||
return graph, err
|
||||
}
|
||||
return graph, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitgraph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
)
|
||||
|
||||
// NewGraph creates a basic graph
|
||||
func NewGraph() *Graph {
|
||||
graph := &Graph{}
|
||||
graph.relationCommit = &Commit{
|
||||
Row: -1,
|
||||
Column: -1,
|
||||
}
|
||||
graph.Flows = map[int64]*Flow{}
|
||||
return graph
|
||||
}
|
||||
|
||||
// Graph represents a collection of flows
|
||||
type Graph struct {
|
||||
Flows map[int64]*Flow
|
||||
Commits []*Commit
|
||||
MinRow int
|
||||
MinColumn int
|
||||
MaxRow int
|
||||
MaxColumn int
|
||||
relationCommit *Commit
|
||||
}
|
||||
|
||||
// Width returns the width of the graph
|
||||
func (graph *Graph) Width() int {
|
||||
return graph.MaxColumn - graph.MinColumn + 1
|
||||
}
|
||||
|
||||
// Height returns the height of the graph
|
||||
func (graph *Graph) Height() int {
|
||||
return graph.MaxRow - graph.MinRow + 1
|
||||
}
|
||||
|
||||
// AddGlyph adds glyph to flows
|
||||
func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) {
|
||||
flow, ok := graph.Flows[flowID]
|
||||
if !ok {
|
||||
flow = NewFlow(flowID, color, row, column)
|
||||
graph.Flows[flowID] = flow
|
||||
}
|
||||
flow.AddGlyph(row, column, glyph)
|
||||
|
||||
if row < graph.MinRow {
|
||||
graph.MinRow = row
|
||||
}
|
||||
if row > graph.MaxRow {
|
||||
graph.MaxRow = row
|
||||
}
|
||||
if column < graph.MinColumn {
|
||||
graph.MinColumn = column
|
||||
}
|
||||
if column > graph.MaxColumn {
|
||||
graph.MaxColumn = column
|
||||
}
|
||||
}
|
||||
|
||||
// AddCommit adds a commit at row, column on flowID with the provided data
|
||||
func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error {
|
||||
commit, err := NewCommit(row, column, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit.Flow = flowID
|
||||
graph.Commits = append(graph.Commits, commit)
|
||||
|
||||
graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAndProcessCommits will load the git.Commits for each commit in the graph,
|
||||
// the associate the commit with the user author, and check the commit verification
|
||||
// before finally retrieving the latest status
|
||||
func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error {
|
||||
var err error
|
||||
var ok bool
|
||||
|
||||
emails := map[string]*user_model.User{}
|
||||
keyMap := map[string]bool{}
|
||||
|
||||
for _, c := range graph.Commits {
|
||||
if len(c.Rev) == 0 {
|
||||
continue
|
||||
}
|
||||
c.Commit, err = gitRepo.GetCommit(c.Rev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err)
|
||||
}
|
||||
|
||||
if c.Commit.Author != nil {
|
||||
email := c.Commit.Author.Email
|
||||
if c.User, ok = emails[email]; !ok {
|
||||
c.User, _ = user_model.GetUserByEmail(ctx, email)
|
||||
emails[email] = c.User
|
||||
}
|
||||
}
|
||||
|
||||
c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit)
|
||||
|
||||
_ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
|
||||
return repo_model.IsOwnerMemberCollaborator(ctx, repository, user.ID)
|
||||
}, &keyMap)
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repository.ID, c.Commit.ID.String(), db.ListOptionsAll)
|
||||
if err != nil {
|
||||
log.Error("GetLatestCommitStatus: %v", err)
|
||||
} else {
|
||||
c.Status = git_model.CalcCommitStatus(statuses)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewFlow creates a new flow
|
||||
func NewFlow(flowID int64, color, row, column int) *Flow {
|
||||
return &Flow{
|
||||
ID: flowID,
|
||||
ColorNumber: color,
|
||||
MinRow: row,
|
||||
MinColumn: column,
|
||||
MaxRow: row,
|
||||
MaxColumn: column,
|
||||
}
|
||||
}
|
||||
|
||||
// Flow represents a series of glyphs
|
||||
type Flow struct {
|
||||
ID int64
|
||||
ColorNumber int
|
||||
Glyphs []Glyph
|
||||
Commits []*Commit
|
||||
MinRow int
|
||||
MinColumn int
|
||||
MaxRow int
|
||||
MaxColumn int
|
||||
}
|
||||
|
||||
// Color16 wraps the color numbers around mod 16
|
||||
func (flow *Flow) Color16() int {
|
||||
return flow.ColorNumber % 16
|
||||
}
|
||||
|
||||
// AddGlyph adds glyph at row and column
|
||||
func (flow *Flow) AddGlyph(row, column int, glyph byte) {
|
||||
if row < flow.MinRow {
|
||||
flow.MinRow = row
|
||||
}
|
||||
if row > flow.MaxRow {
|
||||
flow.MaxRow = row
|
||||
}
|
||||
if column < flow.MinColumn {
|
||||
flow.MinColumn = column
|
||||
}
|
||||
if column > flow.MaxColumn {
|
||||
flow.MaxColumn = column
|
||||
}
|
||||
|
||||
flow.Glyphs = append(flow.Glyphs, Glyph{
|
||||
row,
|
||||
column,
|
||||
glyph,
|
||||
})
|
||||
}
|
||||
|
||||
// Glyph represents a coordinate and glyph
|
||||
type Glyph struct {
|
||||
Row int
|
||||
Column int
|
||||
Glyph byte
|
||||
}
|
||||
|
||||
// RelationCommit represents an empty relation commit
|
||||
var RelationCommit = &Commit{
|
||||
Row: -1,
|
||||
}
|
||||
|
||||
func parseGitTime(timeStr string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, timeStr)
|
||||
if err != nil {
|
||||
return time.Unix(0, 0)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// NewCommit creates a new commit from a provided line
|
||||
func NewCommit(row, column int, line []byte) (*Commit, error) {
|
||||
data := bytes.SplitN(line, []byte("|"), 5)
|
||||
if len(data) < 5 {
|
||||
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
|
||||
}
|
||||
return &Commit{
|
||||
Row: row,
|
||||
Column: column,
|
||||
// 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1)
|
||||
Refs: newRefsFromRefNames(data[0]),
|
||||
// 1 matches git log --pretty=format:%H => commit hash
|
||||
Rev: string(data[1]),
|
||||
// 2 matches git log --pretty=format:%ad => author date (format respects --date= option)
|
||||
Date: parseGitTime(string(data[2])),
|
||||
// 3 matches git log --pretty=format:%h => abbreviated commit hash
|
||||
ShortRev: string(data[3]),
|
||||
// 4 matches git log --pretty=format:%s => subject
|
||||
Subject: string(data[4]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newRefsFromRefNames(refNames []byte) []git.Reference {
|
||||
refBytes := bytes.Split(refNames, []byte{',', ' '})
|
||||
refs := make([]git.Reference, 0, len(refBytes))
|
||||
for _, refNameBytes := range refBytes {
|
||||
if len(refNameBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
refName := string(refNameBytes)
|
||||
if after, ok := strings.CutPrefix(refName, "tag: "); ok {
|
||||
refName = after
|
||||
} else {
|
||||
refName = strings.TrimPrefix(refName, "HEAD -> ")
|
||||
}
|
||||
refs = append(refs, git.Reference{
|
||||
Name: refName,
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// Commit represents a commit at coordinate X, Y with the data
|
||||
type Commit struct {
|
||||
Commit *git.Commit
|
||||
User *user_model.User
|
||||
Verification *asymkey_model.CommitVerification
|
||||
Status *git_model.CommitStatus
|
||||
Flow int64
|
||||
Row int
|
||||
Column int
|
||||
Refs []git.Reference
|
||||
Rev string
|
||||
Date time.Time
|
||||
ShortRev string
|
||||
Subject string
|
||||
}
|
||||
|
||||
// OnlyRelation returns whether this a relation only commit
|
||||
func (c *Commit) OnlyRelation() bool {
|
||||
return c.Row == -1
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitgraph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func BenchmarkGetCommitGraph(b *testing.B) {
|
||||
currentRepo, err := git.OpenRepository(b.Context(), ".")
|
||||
if err != nil || currentRepo == nil {
|
||||
b.Error("Could not open repository")
|
||||
}
|
||||
defer currentRepo.Close()
|
||||
|
||||
for b.Loop() {
|
||||
graph, err := GetCommitGraph(currentRepo, 1, 0, false, nil, nil)
|
||||
if err != nil {
|
||||
b.Error("Could get commit graph")
|
||||
}
|
||||
|
||||
if len(graph.Commits) < 100 {
|
||||
b.Error("Should get 100 log lines.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseCommitString(b *testing.B) {
|
||||
testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
|
||||
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
for b.Loop() {
|
||||
parser.Reset()
|
||||
graph := NewGraph()
|
||||
if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil {
|
||||
b.Error("could not parse teststring")
|
||||
}
|
||||
if graph.Flows[1].Commits[0].Rev != "4e61bacab44e9b4730e44a6615d04098dd3a8eaf" {
|
||||
b.Error("Did not get expected data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseGlyphs(b *testing.B) {
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
tgBytes := []byte(testglyphs)
|
||||
var tg []byte
|
||||
for b.Loop() {
|
||||
parser.Reset()
|
||||
tg = tgBytes
|
||||
idx := bytes.Index(tg, []byte("\n"))
|
||||
for idx > 0 {
|
||||
parser.ParseGlyphs(tg[:idx])
|
||||
tg = tg[idx+1:]
|
||||
idx = bytes.Index(tg, []byte("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUnusedColors(t *testing.T) {
|
||||
testcases := []struct {
|
||||
availableColors []int
|
||||
oldColors []int
|
||||
firstInUse int // these values have to be either be correct or suggest less is
|
||||
firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it
|
||||
}{
|
||||
{
|
||||
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||
oldColors: []int{1, 1, 1, 1, 1},
|
||||
firstAvailable: -1,
|
||||
firstInUse: 1,
|
||||
},
|
||||
{
|
||||
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||
oldColors: []int{1, 2, 3, 4},
|
||||
firstAvailable: 6,
|
||||
firstInUse: 0,
|
||||
},
|
||||
{
|
||||
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||
oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0},
|
||||
firstAvailable: 6,
|
||||
firstInUse: 0,
|
||||
},
|
||||
{
|
||||
availableColors: []int{1, 2, 3, 4, 5, 6, 7},
|
||||
oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7},
|
||||
firstAvailable: -1,
|
||||
firstInUse: 0,
|
||||
},
|
||||
{
|
||||
availableColors: []int{1, 2, 3, 4, 5, 6, 7},
|
||||
oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7},
|
||||
firstAvailable: -1,
|
||||
firstInUse: 0,
|
||||
},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
parser.availableColors = append([]int{}, testcase.availableColors...)
|
||||
parser.oldColors = append(parser.oldColors, testcase.oldColors...)
|
||||
parser.firstAvailable = testcase.firstAvailable
|
||||
parser.firstInUse = testcase.firstInUse
|
||||
parser.releaseUnusedColors()
|
||||
|
||||
if parser.firstAvailable == -1 {
|
||||
// All in use
|
||||
for _, color := range parser.availableColors {
|
||||
found := slices.Contains(parser.oldColors, color)
|
||||
if !found {
|
||||
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
|
||||
testcase.availableColors,
|
||||
testcase.oldColors,
|
||||
testcase.firstAvailable,
|
||||
testcase.firstInUse,
|
||||
parser.availableColors,
|
||||
parser.oldColors,
|
||||
parser.firstAvailable,
|
||||
parser.firstInUse,
|
||||
color)
|
||||
}
|
||||
}
|
||||
} else if parser.firstInUse != -1 {
|
||||
// Some in use
|
||||
for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) {
|
||||
color := parser.availableColors[i]
|
||||
found := slices.Contains(parser.oldColors, color)
|
||||
if !found {
|
||||
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
|
||||
testcase.availableColors,
|
||||
testcase.oldColors,
|
||||
testcase.firstAvailable,
|
||||
testcase.firstInUse,
|
||||
parser.availableColors,
|
||||
parser.oldColors,
|
||||
parser.firstAvailable,
|
||||
parser.firstInUse,
|
||||
color)
|
||||
}
|
||||
}
|
||||
for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) {
|
||||
color := parser.availableColors[i]
|
||||
found := slices.Contains(parser.oldColors, color)
|
||||
if found {
|
||||
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
|
||||
testcase.availableColors,
|
||||
testcase.oldColors,
|
||||
testcase.firstAvailable,
|
||||
testcase.firstInUse,
|
||||
parser.availableColors,
|
||||
parser.oldColors,
|
||||
parser.firstAvailable,
|
||||
parser.firstInUse,
|
||||
color)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// None in use
|
||||
for _, color := range parser.oldColors {
|
||||
if color != 0 {
|
||||
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
|
||||
testcase.availableColors,
|
||||
testcase.oldColors,
|
||||
testcase.firstAvailable,
|
||||
testcase.firstInUse,
|
||||
parser.availableColors,
|
||||
parser.oldColors,
|
||||
parser.firstAvailable,
|
||||
parser.firstInUse,
|
||||
color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlyphs(t *testing.T) {
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
tgBytes := []byte(testglyphs)
|
||||
tg := tgBytes
|
||||
idx := bytes.Index(tg, []byte("\n"))
|
||||
row := 0
|
||||
for idx > 0 {
|
||||
parser.ParseGlyphs(tg[:idx])
|
||||
tg = tg[idx+1:]
|
||||
idx = bytes.Index(tg, []byte("\n"))
|
||||
if parser.flows[0] != 1 {
|
||||
t.Errorf("First column flow should be 1 but was %d", parser.flows[0])
|
||||
}
|
||||
colorToFlow := map[int]int64{}
|
||||
flowToColor := map[int64]int{}
|
||||
|
||||
for i, flow := range parser.flows {
|
||||
if flow == 0 {
|
||||
continue
|
||||
}
|
||||
color := parser.colors[i]
|
||||
|
||||
if fColor, in := flowToColor[flow]; in && fColor != color {
|
||||
t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor)
|
||||
}
|
||||
flowToColor[flow] = color
|
||||
if cFlow, in := colorToFlow[color]; in && cFlow != flow {
|
||||
t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow)
|
||||
}
|
||||
colorToFlow[color] = flow
|
||||
}
|
||||
row++
|
||||
}
|
||||
assert.Len(t, parser.availableColors, 9)
|
||||
}
|
||||
|
||||
func TestCommitStringParsing(t *testing.T) {
|
||||
dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
|
||||
tests := []struct {
|
||||
shouldPass bool
|
||||
testName string
|
||||
commitMessage string
|
||||
}{
|
||||
{true, "normal", "not a fancy message"},
|
||||
{true, "extra pipe", "An extra pipe: |"},
|
||||
{true, "extra 'Data:'", "DATA: might be trouble"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.testName, func(t *testing.T) {
|
||||
testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
|
||||
_, after, _ := strings.Cut(testString, "DATA:")
|
||||
commit, err := NewCommit(0, 0, []byte(after))
|
||||
if err != nil && test.shouldPass {
|
||||
t.Errorf("Could not parse %s", testString)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, test.commitMessage, commit.Subject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var testglyphs = `*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
|\
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
| *
|
||||
* |
|
||||
| *
|
||||
| |\
|
||||
* | |
|
||||
| | *
|
||||
| | |\
|
||||
* | | \
|
||||
|\ \ \ \
|
||||
| * | | |
|
||||
| |\| | |
|
||||
* | | | |
|
||||
|/ / / /
|
||||
| | | *
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
|\ \ \ \
|
||||
| | * | |
|
||||
| | |\| |
|
||||
| | | * |
|
||||
| | | | *
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
|\ \ \ \ \
|
||||
| * | | | |
|
||||
|/| | | | |
|
||||
| | |/ / /
|
||||
| |/| | |
|
||||
| | | | *
|
||||
| * | | |
|
||||
|/| | | |
|
||||
| * | | |
|
||||
|/| | | |
|
||||
| | |/ /
|
||||
| |/| |
|
||||
| * | |
|
||||
| * | |
|
||||
| |\ \ \
|
||||
| | * | |
|
||||
| |/| | |
|
||||
| | | |/
|
||||
| | |/|
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| | * |
|
||||
| | |\ \
|
||||
| | | * |
|
||||
| | |/| |
|
||||
| | | * |
|
||||
| | | |\ \
|
||||
| | | | * |
|
||||
| | | |/| |
|
||||
| | * | | |
|
||||
| | * | | |
|
||||
| | |\ \ \ \
|
||||
| | | * | | |
|
||||
| | |/| | | |
|
||||
| | | | | * |
|
||||
| | | | |/ /
|
||||
* | | | / /
|
||||
|/ / / / /
|
||||
* | | | |
|
||||
|\ \ \ \ \
|
||||
| * | | | |
|
||||
|/| | | | |
|
||||
| * | | | |
|
||||
| * | | | |
|
||||
| |\ \ \ \ \
|
||||
| | | * \ \ \
|
||||
| | | |\ \ \ \
|
||||
| | | | * | | |
|
||||
| | | |/| | | |
|
||||
| | | | | |/ /
|
||||
| | | | |/| |
|
||||
* | | | | | |
|
||||
* | | | | | |
|
||||
* | | | | | |
|
||||
| | | | * | |
|
||||
* | | | | | |
|
||||
| | * | | | |
|
||||
| |/| | | | |
|
||||
* | | | | | |
|
||||
| |/ / / / /
|
||||
|/| | | | |
|
||||
| | | | * |
|
||||
| | | |/ /
|
||||
| | |/| |
|
||||
| * | | |
|
||||
| | | | *
|
||||
| | * | |
|
||||
| | |\ \ \
|
||||
| | | * | |
|
||||
| | |/| | |
|
||||
| | | |/ /
|
||||
| | | * |
|
||||
| | * | |
|
||||
| | |\ \ \
|
||||
| | | * | |
|
||||
| | |/| | |
|
||||
| | | |/ /
|
||||
| | | * |
|
||||
* | | | |
|
||||
|\ \ \ \ \
|
||||
| * \ \ \ \
|
||||
| |\ \ \ \ \
|
||||
| | | |/ / /
|
||||
| | |/| | |
|
||||
| | | | * |
|
||||
| | | | * |
|
||||
* | | | | |
|
||||
* | | | | |
|
||||
|/ / / / /
|
||||
| | | * |
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
|\ \ \ \ \
|
||||
| * | | | |
|
||||
|/| | | | |
|
||||
| | * | | |
|
||||
| | |\ \ \ \
|
||||
| | | * | | |
|
||||
| | |/| | | |
|
||||
| |/| | |/ /
|
||||
| | | |/| |
|
||||
| | | | | *
|
||||
| |_|_|_|/
|
||||
|/| | | |
|
||||
| | * | |
|
||||
| |/ / /
|
||||
* | | |
|
||||
* | | |
|
||||
| | * |
|
||||
* | | |
|
||||
* | | |
|
||||
| * | |
|
||||
| | * |
|
||||
| * | |
|
||||
* | | |
|
||||
|\ \ \ \
|
||||
| * | | |
|
||||
|/| | | |
|
||||
| |/ / /
|
||||
| * | |
|
||||
| |\ \ \
|
||||
| | * | |
|
||||
| |/| | |
|
||||
| | |/ /
|
||||
| | * |
|
||||
| | |\ \
|
||||
| | | * |
|
||||
| | |/| |
|
||||
* | | | |
|
||||
* | | | |
|
||||
|\ \ \ \ \
|
||||
| * | | | |
|
||||
|/| | | | |
|
||||
| | * | | |
|
||||
| | * | | |
|
||||
| | * | | |
|
||||
| |/ / / /
|
||||
| * | | |
|
||||
| |\ \ \ \
|
||||
| | * | | |
|
||||
| |/| | | |
|
||||
* | | | | |
|
||||
* | | | | |
|
||||
* | | | | |
|
||||
* | | | | |
|
||||
* | | | | |
|
||||
| | | | * |
|
||||
* | | | | |
|
||||
|\ \ \ \ \ \
|
||||
| * | | | | |
|
||||
|/| | | | | |
|
||||
| | | | | * |
|
||||
| | | | |/ /
|
||||
* | | | | |
|
||||
|\ \ \ \ \ \
|
||||
* | | | | | |
|
||||
* | | | | | |
|
||||
| | | | * | |
|
||||
* | | | | | |
|
||||
* | | | | | |
|
||||
|\ \ \ \ \ \ \
|
||||
| | |_|_|/ / /
|
||||
| |/| | | | |
|
||||
| | | | * | |
|
||||
| | | | * | |
|
||||
| | | | * | |
|
||||
| | | | * | |
|
||||
| | | | * | |
|
||||
| | | | * | |
|
||||
| | | |/ / /
|
||||
| | | * | |
|
||||
| | | * | |
|
||||
| | | * | |
|
||||
| | |/| | |
|
||||
| | | * | |
|
||||
| | |/| | |
|
||||
| | | |/ /
|
||||
| | * | |
|
||||
| |/| | |
|
||||
| | | * |
|
||||
| | |/ /
|
||||
| | * |
|
||||
| * | |
|
||||
| |\ \ \
|
||||
| * | | |
|
||||
| | * | |
|
||||
| |/| | |
|
||||
| | |/ /
|
||||
| | * |
|
||||
| | |\ \
|
||||
| | * | |
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | * | |
|
||||
| * | | |
|
||||
| |\| | |
|
||||
| * | | |
|
||||
| | * | |
|
||||
| | * | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | * | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | * | |
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| | * | |
|
||||
| * | | |
|
||||
| |\| | |
|
||||
| | * | |
|
||||
| | * | |
|
||||
| | * | |
|
||||
| | | * |
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| | * | |
|
||||
| | |/ /
|
||||
| * | |
|
||||
| * | |
|
||||
| |\| |
|
||||
* | | |
|
||||
|\| | |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| * | |
|
||||
| | * |
|
||||
| * | |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| |\| |
|
||||
| | * |
|
||||
| | |\ \
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| * | | |
|
||||
| |\| | |
|
||||
| | * | |
|
||||
| | | * |
|
||||
| | |/ /
|
||||
* | | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| |\| |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| | | *
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
| | | *
|
||||
| | | |\
|
||||
* | | | |
|
||||
| |_|_|/
|
||||
|/| | |
|
||||
| * | |
|
||||
| |\| |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| | * |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
|/| | |
|
||||
| |/ /
|
||||
| * |
|
||||
| |\ \
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| | * |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
| | * |
|
||||
| | |\ \
|
||||
| | |/ /
|
||||
| |/| |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| |\ \ \
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | | * |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | |/ /
|
||||
| |/| |
|
||||
| | * |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| |\ \ \
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| | | | *
|
||||
| | | | |\
|
||||
| |_|_|_|/
|
||||
|/| | | |
|
||||
| * | | |
|
||||
* | | | |
|
||||
* | | | |
|
||||
|\| | | |
|
||||
| * | | |
|
||||
| |\ \ \ \
|
||||
| | | |/ /
|
||||
| | |/| |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| * | | |
|
||||
| | * | |
|
||||
| | | * |
|
||||
| | |/ /
|
||||
| |/| |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
* | | |
|
||||
| | | *
|
||||
* | | |
|
||||
|\| | |
|
||||
| * | |
|
||||
| * | |
|
||||
| * | |
|
||||
`
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitgraph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Parser represents a git graph parser. It is stateful containing the previous
|
||||
// glyphs, detected flows and color assignments.
|
||||
type Parser struct {
|
||||
glyphs []byte
|
||||
oldGlyphs []byte
|
||||
flows []int64
|
||||
oldFlows []int64
|
||||
maxFlow int64
|
||||
colors []int
|
||||
oldColors []int
|
||||
availableColors []int
|
||||
nextAvailable int
|
||||
firstInUse int
|
||||
firstAvailable int
|
||||
maxAllowedColors int
|
||||
}
|
||||
|
||||
// Reset resets the internal parser state.
|
||||
func (parser *Parser) Reset() {
|
||||
parser.glyphs = parser.glyphs[0:0]
|
||||
parser.oldGlyphs = parser.oldGlyphs[0:0]
|
||||
parser.flows = parser.flows[0:0]
|
||||
parser.oldFlows = parser.oldFlows[0:0]
|
||||
parser.maxFlow = 0
|
||||
parser.colors = parser.colors[0:0]
|
||||
parser.oldColors = parser.oldColors[0:0]
|
||||
parser.availableColors = parser.availableColors[0:0]
|
||||
parser.availableColors = append(parser.availableColors, 1, 2)
|
||||
parser.nextAvailable = 0
|
||||
parser.firstInUse = -1
|
||||
parser.firstAvailable = 0
|
||||
parser.maxAllowedColors = 0
|
||||
}
|
||||
|
||||
// AddLineToGraph adds the line as a row to the graph
|
||||
func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
|
||||
before, after, ok := bytes.Cut(line, []byte("DATA:"))
|
||||
if !ok {
|
||||
parser.ParseGlyphs(line)
|
||||
} else {
|
||||
parser.ParseGlyphs(before)
|
||||
}
|
||||
|
||||
var err error
|
||||
commitDone := false
|
||||
|
||||
for column, glyph := range parser.glyphs {
|
||||
if glyph == ' ' {
|
||||
continue
|
||||
}
|
||||
|
||||
flowID := parser.flows[column]
|
||||
|
||||
graph.AddGlyph(row, column, flowID, parser.colors[column], glyph)
|
||||
|
||||
if glyph == '*' {
|
||||
if commitDone {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err)
|
||||
} else {
|
||||
err = fmt.Errorf("double commit on line %d: %s", row, string(line))
|
||||
}
|
||||
}
|
||||
commitDone = true
|
||||
if !ok {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err)
|
||||
} else {
|
||||
err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line))
|
||||
}
|
||||
continue
|
||||
}
|
||||
err2 := graph.AddCommit(row, column, flowID, after)
|
||||
if err != nil && err2 != nil {
|
||||
err = fmt.Errorf("%v %w", err2, err)
|
||||
continue
|
||||
} else if err2 != nil {
|
||||
err = err2
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if !commitDone {
|
||||
graph.Commits = append(graph.Commits, RelationCommit)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (parser *Parser) releaseUnusedColors() {
|
||||
if parser.firstInUse > -1 {
|
||||
// Here we step through the old colors, searching for them in the
|
||||
// "in-use" section of availableColors (that is, the colors between
|
||||
// firstInUse and firstAvailable)
|
||||
// Ensure that the benchmarks are not worsened with proposed changes
|
||||
stepstaken := 0
|
||||
position := parser.firstInUse
|
||||
for _, color := range parser.oldColors {
|
||||
if color == 0 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
i := position
|
||||
for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ {
|
||||
colorToCheck := parser.availableColors[i]
|
||||
if colorToCheck == color {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
i = (i + 1) % len(parser.availableColors)
|
||||
}
|
||||
if !found {
|
||||
// Duplicate color
|
||||
continue
|
||||
}
|
||||
// Swap them around
|
||||
parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position]
|
||||
stepstaken++
|
||||
position = (parser.firstInUse + stepstaken) % len(parser.availableColors)
|
||||
if position == parser.firstAvailable || stepstaken == len(parser.availableColors) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if stepstaken == len(parser.availableColors) {
|
||||
parser.firstAvailable = -1
|
||||
} else {
|
||||
parser.firstAvailable = position
|
||||
if parser.nextAvailable == -1 {
|
||||
parser.nextAvailable = parser.firstAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseGlyphs parses the provided glyphs and sets the internal state
|
||||
func (parser *Parser) ParseGlyphs(glyphs []byte) {
|
||||
// Clean state for parsing this row
|
||||
parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs
|
||||
parser.glyphs = parser.glyphs[0:0]
|
||||
parser.flows, parser.oldFlows = parser.oldFlows, parser.flows
|
||||
parser.flows = parser.flows[0:0]
|
||||
parser.colors, parser.oldColors = parser.oldColors, parser.colors
|
||||
|
||||
// Ensure we have enough flows and colors
|
||||
parser.colors = parser.colors[0:0]
|
||||
for range glyphs {
|
||||
parser.flows = append(parser.flows, 0)
|
||||
parser.colors = append(parser.colors, 0)
|
||||
}
|
||||
|
||||
// Copy the provided glyphs in to state.glyphs for safekeeping
|
||||
parser.glyphs = append(parser.glyphs, glyphs...)
|
||||
|
||||
// release unused colors
|
||||
parser.releaseUnusedColors()
|
||||
|
||||
for i, glyph := range slices.Backward(glyphs) {
|
||||
switch glyph {
|
||||
case '|':
|
||||
fallthrough
|
||||
case '*':
|
||||
parser.setUpFlow(i)
|
||||
case '/':
|
||||
parser.setOutFlow(i)
|
||||
case '\\':
|
||||
parser.setInFlow(i)
|
||||
case '_':
|
||||
parser.setRightFlow(i)
|
||||
case '.':
|
||||
fallthrough
|
||||
case '-':
|
||||
parser.setLeftFlow(i)
|
||||
case ' ':
|
||||
// no-op
|
||||
default:
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *Parser) takePreviousFlow(i, j int) {
|
||||
if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 {
|
||||
parser.flows[i] = parser.oldFlows[j]
|
||||
parser.oldFlows[j] = 0
|
||||
parser.colors[i] = parser.oldColors[j]
|
||||
parser.oldColors[j] = 0
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *Parser) takeCurrentFlow(i, j int) {
|
||||
if j < len(parser.flows) && parser.flows[j] > 0 {
|
||||
parser.flows[i] = parser.flows[j]
|
||||
parser.colors[i] = parser.colors[j]
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *Parser) newFlow(i int) {
|
||||
parser.maxFlow++
|
||||
parser.flows[i] = parser.maxFlow
|
||||
|
||||
// Now give this flow a color
|
||||
if parser.nextAvailable == -1 {
|
||||
next := len(parser.availableColors)
|
||||
if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors {
|
||||
parser.nextAvailable = next
|
||||
parser.firstAvailable = next
|
||||
parser.availableColors = append(parser.availableColors, next+1)
|
||||
}
|
||||
}
|
||||
parser.colors[i] = parser.availableColors[parser.nextAvailable]
|
||||
if parser.firstInUse == -1 {
|
||||
parser.firstInUse = parser.nextAvailable
|
||||
}
|
||||
parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable]
|
||||
|
||||
parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors)
|
||||
parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors)
|
||||
|
||||
if parser.nextAvailable == parser.firstInUse {
|
||||
parser.nextAvailable = parser.firstAvailable
|
||||
}
|
||||
if parser.nextAvailable == parser.firstInUse {
|
||||
parser.nextAvailable = -1
|
||||
parser.firstAvailable = -1
|
||||
}
|
||||
}
|
||||
|
||||
// setUpFlow handles '|' or '*'
|
||||
func (parser *Parser) setUpFlow(i int) {
|
||||
// In preference order:
|
||||
//
|
||||
// Previous Row: '\? ' ' |' ' /'
|
||||
// Current Row: ' | ' ' |' ' | '
|
||||
if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' {
|
||||
parser.takePreviousFlow(i, i-1)
|
||||
} else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') {
|
||||
parser.takePreviousFlow(i, i)
|
||||
} else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' {
|
||||
parser.takePreviousFlow(i, i+1)
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
// setOutFlow handles '/'
|
||||
func (parser *Parser) setOutFlow(i int) {
|
||||
// In preference order:
|
||||
//
|
||||
// Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\'
|
||||
// Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/'
|
||||
if i+2 < len(parser.oldGlyphs) &&
|
||||
(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') &&
|
||||
(parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') &&
|
||||
i+1 < len(parser.glyphs) &&
|
||||
(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') {
|
||||
parser.takePreviousFlow(i, i+2)
|
||||
} else if i+1 < len(parser.oldGlyphs) &&
|
||||
(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' ||
|
||||
parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') {
|
||||
parser.takePreviousFlow(i, i+1)
|
||||
if parser.oldGlyphs[i+1] == '/' {
|
||||
parser.glyphs[i] = '|'
|
||||
}
|
||||
} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' {
|
||||
parser.takePreviousFlow(i, i)
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
// setInFlow handles '\'
|
||||
func (parser *Parser) setInFlow(i int) {
|
||||
// In preference order:
|
||||
//
|
||||
// Previous Row: '| ' '-. ' '| ' '\ ' '/' '---'
|
||||
// Current Row: '|\' ' \' ' \' ' \' '\' ' \ '
|
||||
if i > 0 && i-1 < len(parser.oldGlyphs) &&
|
||||
(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') &&
|
||||
(parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') {
|
||||
parser.newFlow(i)
|
||||
} else if i > 0 && i-1 < len(parser.oldGlyphs) &&
|
||||
(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' ||
|
||||
parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') {
|
||||
parser.takePreviousFlow(i, i-1)
|
||||
if parser.oldGlyphs[i-1] == '\\' {
|
||||
parser.glyphs[i] = '|'
|
||||
}
|
||||
} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' {
|
||||
parser.takePreviousFlow(i, i)
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
// setRightFlow handles '_'
|
||||
func (parser *Parser) setRightFlow(i int) {
|
||||
// In preference order:
|
||||
//
|
||||
// Current Row: '__' '_/' '_|_' '_|/'
|
||||
if i+1 < len(parser.glyphs) &&
|
||||
(parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') {
|
||||
parser.takeCurrentFlow(i, i+1)
|
||||
} else if i+2 < len(parser.glyphs) &&
|
||||
(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') &&
|
||||
(parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') {
|
||||
parser.takeCurrentFlow(i, i+2)
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
|
||||
// setLeftFlow handles '----.'
|
||||
func (parser *Parser) setLeftFlow(i int) {
|
||||
if parser.glyphs[i] == '.' {
|
||||
parser.newFlow(i)
|
||||
} else if i+1 < len(parser.glyphs) &&
|
||||
(parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') {
|
||||
parser.takeCurrentFlow(i, i+1)
|
||||
} else {
|
||||
parser.newFlow(i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// SyncRepositoryHooks rewrites all repositories' pre-receive, update and post-receive hooks
|
||||
// to make sure the binary and custom conf path are up-to-date.
|
||||
func SyncRepositoryHooks(ctx context.Context) error {
|
||||
log.Trace("Doing: SyncRepositoryHooks")
|
||||
|
||||
if err := db.Iterate(
|
||||
ctx,
|
||||
builder.Gt{"id": 0},
|
||||
func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("before sync repository hooks for %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
|
||||
if err := gitrepo.CreateDelegateHooks(ctx, repo); err != nil {
|
||||
return fmt.Errorf("CreateDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
if HasWiki(ctx, repo) {
|
||||
if err := gitrepo.CreateDelegateHooks(ctx, repo.WikiStorageRepo()); err != nil {
|
||||
return fmt.Errorf("CreateDelegateHooks: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace("Finished: SyncRepositoryHooks")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGitHooks generates git hooks from a template repository
|
||||
func GenerateGitHooks(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
generateGitRepo, err := gitrepo.OpenRepository(ctx, generateRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer generateGitRepo.Close()
|
||||
|
||||
templateGitRepo, err := gitrepo.OpenRepository(ctx, templateRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer templateGitRepo.Close()
|
||||
|
||||
templateHooks, err := templateGitRepo.Hooks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, templateHook := range templateHooks {
|
||||
generateHook, err := generateGitRepo.GetHook(templateHook.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generateHook.Content = templateHook.Content
|
||||
if err := generateHook.Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateWebhooks generates webhooks from a template repository
|
||||
func GenerateWebhooks(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
templateWebhooks, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: templateRepo.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws := make([]*webhook.Webhook, 0, len(templateWebhooks))
|
||||
for _, templateWebhook := range templateWebhooks {
|
||||
ws = append(ws, &webhook.Webhook{
|
||||
RepoID: generateRepo.ID,
|
||||
URL: templateWebhook.URL,
|
||||
HTTPMethod: templateWebhook.HTTPMethod,
|
||||
ContentType: templateWebhook.ContentType,
|
||||
Secret: templateWebhook.Secret,
|
||||
HookEvent: templateWebhook.HookEvent,
|
||||
IsActive: templateWebhook.IsActive,
|
||||
Type: templateWebhook.Type,
|
||||
OwnerID: templateWebhook.OwnerID,
|
||||
Events: templateWebhook.Events,
|
||||
Meta: templateWebhook.Meta,
|
||||
})
|
||||
}
|
||||
return webhook.CreateWebhooks(ctx, ws)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
)
|
||||
|
||||
// initRepoCommit temporarily changes with work directory.
|
||||
func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Repository, u *user_model.User) (err error) {
|
||||
commitTimeStr := time.Now().Format(time.RFC3339)
|
||||
|
||||
sig := u.NewGitSig()
|
||||
// Because this may call hooks we should pass in the environment
|
||||
env := append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+sig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+sig.Email,
|
||||
"GIT_AUTHOR_DATE="+commitTimeStr,
|
||||
"GIT_COMMITTER_DATE="+commitTimeStr,
|
||||
)
|
||||
committerName := sig.Name
|
||||
committerEmail := sig.Email
|
||||
|
||||
if stdout, _, err := gitcmd.NewCommand("add", "--all").WithDir(tmpPath).RunStdString(ctx); err != nil {
|
||||
log.Error("git add --all failed: Stdout: %s\nError: %v", stdout, err)
|
||||
return fmt.Errorf("git add --all: %w", err)
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand("commit", "--message=Initial commit").
|
||||
AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email)
|
||||
|
||||
sign, key, signer, _ := asymkey_service.SignInitialCommit(ctx, u)
|
||||
if sign {
|
||||
if key.Format != "" {
|
||||
cmd.AddConfig("gpg.format", key.Format)
|
||||
}
|
||||
cmd.AddOptionFormat("-S%s", key.KeyID)
|
||||
|
||||
if repo.GetTrustModel() == repo_model.CommitterTrustModel || repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
// need to set the committer to the KeyID owner
|
||||
committerName = signer.Name
|
||||
committerEmail = signer.Email
|
||||
}
|
||||
} else {
|
||||
cmd.AddArguments("--no-gpg-sign")
|
||||
}
|
||||
|
||||
env = append(env,
|
||||
"GIT_COMMITTER_NAME="+committerName,
|
||||
"GIT_COMMITTER_EMAIL="+committerEmail,
|
||||
)
|
||||
|
||||
if stdout, _, err := cmd.WithDir(tmpPath).WithEnv(env).RunStdString(ctx); err != nil {
|
||||
log.Error("Failed to commit: %v: Stdout: %s\nError: %v", cmd.LogString(), stdout, err)
|
||||
return fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
|
||||
if err := gitrepo.PushFromLocal(ctx, tmpPath, repo, git.PushOptions{
|
||||
LocalRefName: "HEAD",
|
||||
Branch: repo.DefaultBranch,
|
||||
Env: repo_module.InternalPushingEnvironment(u, repo),
|
||||
}); err != nil {
|
||||
log.Error("Failed to push back to HEAD Error: %v", err)
|
||||
return fmt.Errorf("git push: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// GarbageCollectLFSMetaObjectsOptions provides options for GarbageCollectLFSMetaObjects function
|
||||
type GarbageCollectLFSMetaObjectsOptions struct {
|
||||
LogDetail func(format string, v ...any)
|
||||
AutoFix bool
|
||||
OlderThan time.Time
|
||||
UpdatedLessRecentlyThan time.Time
|
||||
NumberToCheckPerRepo int64
|
||||
ProportionToCheckPerRepo float64
|
||||
}
|
||||
|
||||
// GarbageCollectLFSMetaObjects garbage collects LFS objects for all repositories
|
||||
func GarbageCollectLFSMetaObjects(ctx context.Context, opts GarbageCollectLFSMetaObjectsOptions) error {
|
||||
log.Trace("Doing: GarbageCollectLFSMetaObjects")
|
||||
defer log.Trace("Finished: GarbageCollectLFSMetaObjects")
|
||||
|
||||
if opts.LogDetail == nil {
|
||||
opts.LogDetail = log.Debug
|
||||
}
|
||||
|
||||
if !setting.LFS.StartServer {
|
||||
opts.LogDetail("LFS support is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
return git_model.IterateRepositoryIDsWithLFSMetaObjects(ctx, func(ctx context.Context, repoID, count int64) error {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if newMinimum := int64(float64(count) * opts.ProportionToCheckPerRepo); newMinimum > opts.NumberToCheckPerRepo && opts.NumberToCheckPerRepo != 0 {
|
||||
opts.NumberToCheckPerRepo = newMinimum
|
||||
}
|
||||
return GarbageCollectLFSMetaObjectsForRepo(ctx, repo, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// GarbageCollectLFSMetaObjectsForRepo garbage collects LFS objects for a specific repository
|
||||
func GarbageCollectLFSMetaObjectsForRepo(ctx context.Context, repo *repo_model.Repository, opts GarbageCollectLFSMetaObjectsOptions) error {
|
||||
opts.LogDetail("Checking %-v", repo)
|
||||
total, orphaned, collected, deleted := int64(0), 0, 0, 0
|
||||
defer func() {
|
||||
if orphaned == 0 {
|
||||
opts.LogDetail("Found %d total LFSMetaObjects in %-v", total, repo)
|
||||
} else if !opts.AutoFix {
|
||||
opts.LogDetail("Found %d/%d orphaned LFSMetaObjects in %-v", orphaned, total, repo)
|
||||
} else {
|
||||
opts.LogDetail("Collected %d/%d orphaned/%d total LFSMetaObjects in %-v. %d removed from storage.", collected, orphaned, total, repo, deleted)
|
||||
}
|
||||
}()
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to open git repository %-v: %v", repo, err)
|
||||
return err
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
store := lfs.NewContentStore()
|
||||
errStop := errors.New("STOPERR")
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
|
||||
err = git_model.IterateLFSMetaObjectsForRepo(ctx, repo.ID, func(ctx context.Context, metaObject *git_model.LFSMetaObject, count int64) error {
|
||||
if opts.NumberToCheckPerRepo > 0 && total > opts.NumberToCheckPerRepo {
|
||||
return errStop
|
||||
}
|
||||
total++
|
||||
pointerSha := git.ComputeBlobHash(objectFormat, []byte(metaObject.Pointer.StringContent()))
|
||||
|
||||
if gitRepo.IsObjectExist(pointerSha.String()) {
|
||||
return git_model.MarkLFSMetaObject(ctx, metaObject.ID)
|
||||
}
|
||||
orphaned++
|
||||
|
||||
if !opts.AutoFix {
|
||||
return nil
|
||||
}
|
||||
// Non-existent pointer file
|
||||
_, err = git_model.RemoveLFSMetaObjectByOidFn(ctx, repo.ID, metaObject.Oid, func(count int64) error {
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := store.Delete(metaObject.RelativePath()); err != nil {
|
||||
log.Error("Unable to remove lfs metaobject %s from store: %v", metaObject.Oid, err)
|
||||
}
|
||||
deleted++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to remove meta-object %s in %s: %w", metaObject.Oid, repo.FullName(), err)
|
||||
}
|
||||
collected++
|
||||
|
||||
return nil
|
||||
}, &git_model.IterateLFSMetaObjectsForRepoOptions{
|
||||
// Only attempt to garbage collect lfs meta objects older than a week as the order of git lfs upload
|
||||
// and git object upload is not necessarily guaranteed. It's possible to imagine a situation whereby
|
||||
// an LFS object is uploaded but the git branch is not uploaded immediately, or there are some rapid
|
||||
// changes in new branches that might lead to lfs objects becoming temporarily unassociated with git
|
||||
// objects.
|
||||
//
|
||||
// It is likely that a week is potentially excessive but it should definitely be enough that any
|
||||
// unassociated LFS object is genuinely unassociated.
|
||||
OlderThan: timeutil.TimeStamp(opts.OlderThan.Unix()),
|
||||
UpdatedLessRecentlyThan: timeutil.TimeStamp(opts.UpdatedLessRecentlyThan.Unix()),
|
||||
})
|
||||
|
||||
if err == errStop {
|
||||
opts.LogDetail("Processing stopped at %d total LFSMetaObjects in %-v", total, repo)
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/test"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGarbageCollectLFSMetaObjects(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
|
||||
|
||||
err := storage.Init()
|
||||
assert.NoError(t, err)
|
||||
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", "repo1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// add lfs object
|
||||
lfsContent := []byte("gitea1")
|
||||
lfsOid := storeObjectInRepo(t, repo.ID, &lfsContent)
|
||||
|
||||
// gc
|
||||
err = repo_service.GarbageCollectLFSMetaObjects(t.Context(), repo_service.GarbageCollectLFSMetaObjectsOptions{
|
||||
AutoFix: true,
|
||||
OlderThan: time.Now().Add(7 * 24 * time.Hour).Add(5 * 24 * time.Hour),
|
||||
UpdatedLessRecentlyThan: time.Now().Add(7 * 24 * time.Hour).Add(3 * 24 * time.Hour),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// lfs meta has been deleted
|
||||
_, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, lfsOid)
|
||||
assert.ErrorIs(t, err, git_model.ErrLFSObjectNotExist)
|
||||
}
|
||||
|
||||
func TestGarbageCollectLFSMetaObjectsForRepoAutoFix(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
|
||||
|
||||
err := storage.Init()
|
||||
assert.NoError(t, err)
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
// add lfs object
|
||||
lfsContent := []byte("gitea2")
|
||||
lfsOid := storeObjectInRepo(t, repo.ID, &lfsContent)
|
||||
|
||||
err = repo_service.GarbageCollectLFSMetaObjectsForRepo(t.Context(), repo, repo_service.GarbageCollectLFSMetaObjectsOptions{
|
||||
LogDetail: func(string, ...any) {},
|
||||
AutoFix: true,
|
||||
OlderThan: time.Now().Add(24 * time.Hour * 7),
|
||||
UpdatedLessRecentlyThan: time.Now().Add(24 * time.Hour * 3),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, lfsOid)
|
||||
assert.ErrorIs(t, err, git_model.ErrLFSObjectNotExist)
|
||||
}
|
||||
|
||||
func storeObjectInRepo(t *testing.T, repositoryID int64, content *[]byte) string {
|
||||
pointer, err := lfs.GeneratePointer(bytes.NewReader(*content))
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = git_model.NewLFSMetaObject(t.Context(), repositoryID, pointer)
|
||||
assert.NoError(t, err)
|
||||
contentStore := lfs.NewContentStore()
|
||||
exist, err := contentStore.Exists(pointer)
|
||||
assert.NoError(t, err)
|
||||
if !exist {
|
||||
err := contentStore.Put(pointer, bytes.NewReader(*content))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
return pointer.Oid
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/options"
|
||||
"gitea.dev/modules/queue"
|
||||
|
||||
licenseclassifier "github.com/google/licenseclassifier/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
classifier *licenseclassifier.Classifier
|
||||
LicenseFileName = "LICENSE"
|
||||
|
||||
// licenseUpdaterQueue represents a queue to handle update repo licenses
|
||||
licenseUpdaterQueue *queue.WorkerPoolQueue[*LicenseUpdaterOptions]
|
||||
)
|
||||
|
||||
func AddRepoToLicenseUpdaterQueue(opts *LicenseUpdaterOptions) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
return licenseUpdaterQueue.Push(opts)
|
||||
}
|
||||
|
||||
func InitLicenseClassifier() error {
|
||||
// threshold should be 0.84~0.86 or the test will be failed
|
||||
classifier = licenseclassifier.NewClassifier(.85)
|
||||
licenseFiles, err := options.AssetFS().ListFiles("license", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, licenseFile := range licenseFiles {
|
||||
licenseName := licenseFile
|
||||
data, err := options.License(licenseFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
classifier.AddContent("License", licenseName, licenseName, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LicenseUpdaterOptions struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func repoLicenseUpdater(items ...*LicenseUpdaterOptions) []*LicenseUpdaterOptions {
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
|
||||
for _, opts := range items {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, opts.RepoID)
|
||||
if err != nil {
|
||||
log.Error("repoLicenseUpdater [%d] failed: GetRepositoryByID: %v", opts.RepoID, err)
|
||||
continue
|
||||
}
|
||||
if repo.IsEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err)
|
||||
continue
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err)
|
||||
continue
|
||||
}
|
||||
if err = UpdateRepoLicenses(ctx, repo, commit); err != nil {
|
||||
log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncRepoLicenses(ctx context.Context) error {
|
||||
log.Trace("Doing: SyncRepoLicenses")
|
||||
|
||||
if err := db.Iterate(
|
||||
ctx,
|
||||
nil,
|
||||
func(ctx context.Context, repo *repo_model.Repository) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("before sync repo licenses for %s", repo.FullName())
|
||||
default:
|
||||
}
|
||||
return AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{RepoID: repo.ID})
|
||||
},
|
||||
); err != nil {
|
||||
log.Trace("Error: SyncRepoLicenses: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace("Finished: SyncReposLicenses")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRepoLicenses will update repository licenses col if license file exists
|
||||
func UpdateRepoLicenses(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) error {
|
||||
if commit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := commit.GetBlobByPath(LicenseFileName)
|
||||
if err != nil && !git.IsErrNotExist(err) {
|
||||
return fmt.Errorf("GetBlobByPath: %w", err)
|
||||
}
|
||||
|
||||
if git.IsErrNotExist(err) {
|
||||
return repo_model.CleanRepoLicenses(ctx, repo)
|
||||
}
|
||||
|
||||
licenses := make([]string, 0)
|
||||
if b != nil {
|
||||
r, err := b.DataAsync()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
licenses, err = detectLicense(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("detectLicense: %w", err)
|
||||
}
|
||||
}
|
||||
return repo_model.UpdateRepoLicenses(ctx, repo, commit.ID.String(), licenses)
|
||||
}
|
||||
|
||||
// detectLicense returns the licenses detected by the given content buff
|
||||
func detectLicense(r io.Reader) ([]string, error) {
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
matches, err := classifier.MatchFrom(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(matches.Matches) > 0 {
|
||||
results := make(container.Set[string], len(matches.Matches))
|
||||
for _, r := range matches.Matches {
|
||||
if r.MatchType == "License" && !results.Contains(r.Variant) {
|
||||
results.Add(r.Variant)
|
||||
}
|
||||
}
|
||||
return results.Values(), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_detectLicense(t *testing.T) {
|
||||
type DetectLicenseTest struct {
|
||||
name string
|
||||
arg string
|
||||
want []string
|
||||
}
|
||||
|
||||
tests := []DetectLicenseTest{
|
||||
{
|
||||
name: "empty",
|
||||
arg: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no detected license",
|
||||
arg: "Copyright (c) 2023 Gitea",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, repo_module.LoadRepoConfig())
|
||||
for _, licenseName := range repo_module.Licenses {
|
||||
license, err := repo_module.GetLicense(licenseName, &repo_module.LicenseValues{
|
||||
Owner: "Gitea",
|
||||
Email: "teabot@gitea.io",
|
||||
Repo: "gitea",
|
||||
Year: "2024",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests = append(tests, DetectLicenseTest{
|
||||
name: "single license test: " + licenseName,
|
||||
arg: string(license),
|
||||
want: []string{licenseName},
|
||||
})
|
||||
}
|
||||
|
||||
require.NoError(t, InitLicenseClassifier())
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
license, err := detectLicense(strings.NewReader(tt.arg))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, license)
|
||||
})
|
||||
}
|
||||
|
||||
result, err := detectLicense(strings.NewReader(tests[2].arg + tests[3].arg + tests[4].arg))
|
||||
assert.NoError(t, err)
|
||||
t.Run("multiple licenses test", func(t *testing.T) {
|
||||
assert.Len(t, result, 3)
|
||||
assert.Contains(t, result, tests[2].want[0])
|
||||
assert.Contains(t, result, tests[3].want[0])
|
||||
assert.Contains(t, result, tests[4].want[0])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
issue_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
// MergeUpstream merges the base repository's default branch into the fork repository's current branch.
|
||||
func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, branch string, ffOnly bool) (mergeStyle string, err error) {
|
||||
if err = repo.MustNotBeArchived(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = repo.GetBaseRepo(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !divergingInfo.BaseBranchHasNewCommits {
|
||||
return "up-to-date", nil
|
||||
}
|
||||
|
||||
err = gitrepo.Push(ctx, repo.BaseRepo, repo, git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s", divergingInfo.BaseBranchName, branch),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
})
|
||||
if err == nil {
|
||||
return "fast-forward", nil
|
||||
}
|
||||
if !git.IsErrPushOutOfDate(err) && !git.IsErrPushRejected(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If ff_only is requested and fast-forward failed, return error
|
||||
if ffOnly {
|
||||
return "", util.NewInvalidArgumentErrorf("fast-forward merge not possible: branch has diverged")
|
||||
}
|
||||
|
||||
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
|
||||
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
|
||||
fakeIssue := &issue_model.Issue{
|
||||
ID: -1,
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
Index: -1,
|
||||
PosterID: doer.ID,
|
||||
Poster: doer,
|
||||
IsPull: true,
|
||||
}
|
||||
fakePR := &issue_model.PullRequest{
|
||||
ID: -1,
|
||||
Status: issue_model.PullRequestStatusMergeable,
|
||||
IssueID: -1,
|
||||
Issue: fakeIssue,
|
||||
Index: -1,
|
||||
HeadRepoID: repo.ID,
|
||||
HeadRepo: repo,
|
||||
BaseRepoID: repo.BaseRepo.ID,
|
||||
BaseRepo: repo.BaseRepo,
|
||||
HeadBranch: branch, // maybe HeadCommitID is not needed
|
||||
BaseBranch: divergingInfo.BaseBranchName,
|
||||
}
|
||||
fakeIssue.PullRequest = fakePR
|
||||
err = pull.Update(ctx, fakePR, doer, "merge upstream", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "merge", nil
|
||||
}
|
||||
|
||||
// UpstreamDivergingInfo is also used in templates, so it needs to search for all references before changing it.
|
||||
type UpstreamDivergingInfo struct {
|
||||
BaseBranchName string
|
||||
BaseBranchHasNewCommits bool
|
||||
HeadBranchCommitsBehind int
|
||||
}
|
||||
|
||||
// GetUpstreamDivergingInfo returns the information about the divergence between the fork repository's branch and the base repository's default branch.
|
||||
func GetUpstreamDivergingInfo(ctx reqctx.RequestContext, forkRepo *repo_model.Repository, forkBranch string) (*UpstreamDivergingInfo, error) {
|
||||
if !forkRepo.IsFork {
|
||||
return nil, util.NewInvalidArgumentErrorf("repo is not a fork")
|
||||
}
|
||||
|
||||
if forkRepo.IsArchived {
|
||||
return nil, util.NewInvalidArgumentErrorf("repo is archived")
|
||||
}
|
||||
|
||||
if err := forkRepo.GetBaseRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Do the best to follow the GitHub's behavior, suppose there is a `branch-a` in fork repo:
|
||||
// * if `branch-a` exists in base repo: try to sync `base:branch-a` to `fork:branch-a`
|
||||
// * if `branch-a` doesn't exist in base repo: try to sync `base:main` to `fork:branch-a`
|
||||
info, err := GetBranchDivergingInfo(ctx, forkRepo.BaseRepo, forkBranch, forkRepo, forkBranch)
|
||||
if err == nil {
|
||||
return &UpstreamDivergingInfo{
|
||||
BaseBranchName: forkBranch,
|
||||
BaseBranchHasNewCommits: info.BaseHasNewCommits,
|
||||
HeadBranchCommitsBehind: info.HeadCommitsBehind,
|
||||
}, nil
|
||||
}
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
info, err = GetBranchDivergingInfo(ctx, forkRepo.BaseRepo, forkRepo.BaseRepo.DefaultBranch, forkRepo, forkBranch)
|
||||
if err == nil {
|
||||
return &UpstreamDivergingInfo{
|
||||
BaseBranchName: forkRepo.BaseRepo.DefaultBranch,
|
||||
BaseBranchHasNewCommits: info.BaseHasNewCommits,
|
||||
HeadBranchCommitsBehind: info.HeadCommitsBehind,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/migration"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func cloneWiki(ctx context.Context, repo *repo_model.Repository, opts migration.MigrateOptions, migrateTimeout time.Duration) (string, error) {
|
||||
wikiRemoteURL := repo_module.WikiRemoteURL(ctx, opts.CloneAddr)
|
||||
if wikiRemoteURL == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
storageRepo := repo.WikiStorageRepo()
|
||||
|
||||
if err := gitrepo.DeleteRepository(ctx, storageRepo); err != nil {
|
||||
return "", fmt.Errorf("failed to remove existing wiki dir %q, err: %w", storageRepo.RelativePath(), err)
|
||||
}
|
||||
|
||||
cleanIncompleteWikiPath := func() {
|
||||
if err := gitrepo.DeleteRepository(ctx, storageRepo); err != nil {
|
||||
log.Error("Failed to remove incomplete wiki dir %q, err: %v", storageRepo.RelativePath(), err)
|
||||
}
|
||||
}
|
||||
if err := gitrepo.CloneExternalRepo(ctx, wikiRemoteURL, storageRepo, git.CloneRepoOptions{
|
||||
Mirror: true,
|
||||
Quiet: true,
|
||||
Timeout: migrateTimeout,
|
||||
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
|
||||
}); err != nil {
|
||||
log.Error("Clone wiki failed, err: %v", err)
|
||||
cleanIncompleteWikiPath()
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := gitrepo.WriteCommitGraph(ctx, storageRepo); err != nil {
|
||||
cleanIncompleteWikiPath()
|
||||
return "", err
|
||||
}
|
||||
|
||||
defaultBranch, err := gitrepo.GetDefaultBranch(ctx, storageRepo)
|
||||
if err != nil {
|
||||
cleanIncompleteWikiPath()
|
||||
return "", fmt.Errorf("failed to get wiki repo default branch for %q, err: %w", storageRepo.RelativePath(), err)
|
||||
}
|
||||
|
||||
return defaultBranch, nil
|
||||
}
|
||||
|
||||
// MigrateRepositoryGitData starts migrating git related data after created migrating repository
|
||||
func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
repo *repo_model.Repository, opts migration.MigrateOptions,
|
||||
httpTransport *http.Transport,
|
||||
) (*repo_model.Repository, error) {
|
||||
if u.IsOrganization() {
|
||||
t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo.NumWatches = t.NumMembers
|
||||
} else {
|
||||
repo.NumWatches = 1
|
||||
}
|
||||
|
||||
migrateTimeout := time.Duration(setting.Git.Timeout.Migrate) * time.Second
|
||||
|
||||
if err := gitrepo.DeleteRepository(ctx, repo); err != nil {
|
||||
return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repo.FullName(), err)
|
||||
}
|
||||
|
||||
if err := gitrepo.CloneExternalRepo(ctx, opts.CloneAddr, repo, git.CloneRepoOptions{
|
||||
Mirror: true,
|
||||
Quiet: true,
|
||||
Timeout: migrateTimeout,
|
||||
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err)
|
||||
}
|
||||
return repo, fmt.Errorf("clone error: %w", err)
|
||||
}
|
||||
|
||||
if err := gitrepo.WriteCommitGraph(ctx, repo); err != nil {
|
||||
return repo, err
|
||||
}
|
||||
|
||||
if opts.Wiki {
|
||||
defaultWikiBranch, err := cloneWiki(ctx, repo, opts, migrateTimeout)
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("clone wiki error: %w", err)
|
||||
}
|
||||
repo.DefaultWikiBranch = defaultWikiBranch
|
||||
}
|
||||
|
||||
if repo.OwnerID == u.ID {
|
||||
repo.Owner = u
|
||||
}
|
||||
|
||||
if err := updateGitRepoAfterCreate(ctx, repo); err != nil {
|
||||
return nil, fmt.Errorf("updateGitRepoAfterCreate: %w", err)
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
repo.IsEmpty, err = gitRepo.IsEmpty()
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("git.IsEmpty: %w", err)
|
||||
}
|
||||
|
||||
if !repo.IsEmpty {
|
||||
if len(repo.DefaultBranch) == 0 {
|
||||
// Try to get HEAD branch and set it as default branch.
|
||||
headBranchName, err := gitrepo.GetDefaultBranch(ctx, repo)
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("GetHEADBranch: %w", err)
|
||||
}
|
||||
if headBranchName != "" {
|
||||
repo.DefaultBranch = headBranchName
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, u.ID); err != nil {
|
||||
return repo, fmt.Errorf("SyncRepoBranchesWithRepo: %v", err)
|
||||
}
|
||||
|
||||
// if releases migration are not requested, we will sync all tags here
|
||||
// otherwise, the releases sync will be done out of this function
|
||||
if !opts.Releases {
|
||||
repo.IsMirror = opts.Mirror
|
||||
if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
|
||||
log.Error("Failed to synchronize tags to releases for repository: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.LFS {
|
||||
endpoint := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint)
|
||||
lfsClient := lfs.NewClient(endpoint, httpTransport)
|
||||
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil {
|
||||
log.Error("Failed to store missing LFS objects for repository: %v", err)
|
||||
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update repo license
|
||||
if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{RepoID: repo.ID}); err != nil {
|
||||
log.Error("Failed to add repo to license updater queue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*repo_model.Repository, error) {
|
||||
if opts.Mirror {
|
||||
remoteAddress, err := util.SanitizeURL(opts.CloneAddr)
|
||||
if err != nil {
|
||||
return repo, err
|
||||
}
|
||||
mirrorModel := repo_model.Mirror{
|
||||
RepoID: repo.ID,
|
||||
Interval: setting.Mirror.DefaultInterval,
|
||||
EnablePrune: true,
|
||||
NextUpdateUnix: timeutil.TimeStampNow().AddDuration(setting.Mirror.DefaultInterval),
|
||||
LFS: opts.LFS,
|
||||
RemoteAddress: remoteAddress,
|
||||
}
|
||||
if opts.LFS {
|
||||
mirrorModel.LFSEndpoint = opts.LFSEndpoint
|
||||
}
|
||||
|
||||
if opts.MirrorInterval != "" {
|
||||
parsedInterval, err := time.ParseDuration(opts.MirrorInterval)
|
||||
if err != nil {
|
||||
log.Error("Failed to set Interval: %v", err)
|
||||
return repo, err
|
||||
}
|
||||
if parsedInterval == 0 {
|
||||
mirrorModel.Interval = 0
|
||||
mirrorModel.NextUpdateUnix = 0
|
||||
} else if parsedInterval < setting.Mirror.MinInterval {
|
||||
err := fmt.Errorf("interval %s is set below Minimum Interval of %s", parsedInterval, setting.Mirror.MinInterval)
|
||||
log.Error("Interval: %s is too frequent", opts.MirrorInterval)
|
||||
return repo, err
|
||||
} else {
|
||||
mirrorModel.Interval = parsedInterval
|
||||
mirrorModel.NextUpdateUnix = timeutil.TimeStampNow().AddDuration(parsedInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if err = repo_model.InsertMirror(ctx, &mirrorModel); err != nil {
|
||||
return repo, fmt.Errorf("InsertOne: %w", err)
|
||||
}
|
||||
|
||||
repo.IsMirror = true
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "num_watches", "is_empty", "default_branch", "default_wiki_branch", "is_mirror"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
}
|
||||
|
||||
// this is necessary for sync local tags from remote
|
||||
configName := fmt.Sprintf("remote.%s.fetch", mirrorModel.GetRemoteName())
|
||||
if stdout, _, err := gitrepo.RunCmdString(ctx, repo,
|
||||
gitcmd.NewCommand("config").
|
||||
AddOptionValues("--add", configName, `+refs/tags/*:refs/tags/*`)); err != nil {
|
||||
log.Error("MigrateRepositoryGitData(git config --add <remote> +refs/tags/*:refs/tags/*) in %v: Stdout: %s\nError: %v", repo, stdout, err)
|
||||
return repo, fmt.Errorf("error in MigrateRepositoryGitData(git config --add <remote> +refs/tags/*:refs/tags/*): %w", err)
|
||||
}
|
||||
} else {
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
}
|
||||
if repo, err = CleanUpMigrateInfo(ctx, repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var enableRepoUnits []repo_model.RepoUnit
|
||||
if opts.Releases && !unit_model.TypeReleases.UnitGlobalDisabled() {
|
||||
enableRepoUnits = append(enableRepoUnits, repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeReleases})
|
||||
}
|
||||
if opts.Wiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
|
||||
enableRepoUnits = append(enableRepoUnits, repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeWiki})
|
||||
}
|
||||
if len(enableRepoUnits) > 0 {
|
||||
err = UpdateRepositoryUnits(ctx, repo, enableRepoUnits, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
}
|
||||
|
||||
// CleanUpMigrateInfo finishes migrating repository and/or wiki with things that don't need to be done for mirrors.
|
||||
func CleanUpMigrateInfo(ctx context.Context, repo *repo_model.Repository) (*repo_model.Repository, error) {
|
||||
if err := gitrepo.CreateDelegateHooks(ctx, repo); err != nil {
|
||||
return repo, fmt.Errorf("createDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
hasWiki := HasWiki(ctx, repo)
|
||||
if hasWiki {
|
||||
if err := gitrepo.CreateDelegateHooks(ctx, repo.WikiStorageRepo()); err != nil {
|
||||
return repo, fmt.Errorf("createDelegateHooks.(wiki): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := gitrepo.GitRemoteRemove(ctx, repo, "origin")
|
||||
if err != nil && !git.IsRemoteNotExistError(err) {
|
||||
return repo, fmt.Errorf("CleanUpMigrateInfo: %w", err)
|
||||
}
|
||||
|
||||
if hasWiki {
|
||||
err = gitrepo.GitRemoteRemove(ctx, repo.WikiStorageRepo(), "origin")
|
||||
if err != nil && !git.IsRemoteNotExistError(err) {
|
||||
return repo, fmt.Errorf("cleanUpMigrateGitConfig (wiki): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return repo, UpdateRepository(ctx, repo, false)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/queue"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
// pushQueue represents a queue to handle update pull request tests
|
||||
var pushQueue *queue.WorkerPoolQueue[[]*repo_module.PushUpdateOptions]
|
||||
|
||||
// handle passed PR IDs and test the PRs
|
||||
func handler(items ...[]*repo_module.PushUpdateOptions) [][]*repo_module.PushUpdateOptions {
|
||||
for _, opts := range items {
|
||||
if err := pushUpdates(opts); err != nil {
|
||||
// Username and repository stays the same between items in opts.
|
||||
pushUpdate := opts[0]
|
||||
log.Error("pushUpdate[%s/%s] failed: %v", pushUpdate.RepoUserName, pushUpdate.RepoName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initPushQueue() error {
|
||||
pushQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "push_update", handler)
|
||||
if pushQueue == nil {
|
||||
return errors.New("unable to create push_update queue")
|
||||
}
|
||||
go graceful.GetManager().RunWithCancel(pushQueue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushUpdate is an alias of PushUpdates for single push update options
|
||||
func PushUpdate(opts *repo_module.PushUpdateOptions) error {
|
||||
return PushUpdates([]*repo_module.PushUpdateOptions{opts})
|
||||
}
|
||||
|
||||
// PushUpdates adds a push update to push queue
|
||||
func PushUpdates(opts []*repo_module.PushUpdateOptions) error {
|
||||
if len(opts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt.IsNewRef() && opt.IsDelRef() {
|
||||
return errors.New("Old and new revisions are both NULL")
|
||||
}
|
||||
}
|
||||
|
||||
return pushQueue.Push(opts)
|
||||
}
|
||||
|
||||
// pushUpdates generates push action history feeds for push updating multiple refs
|
||||
func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
if len(optsList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("PushUpdates: %s/%s", optsList[0].RepoUserName, optsList[0].RepoName))
|
||||
defer finished()
|
||||
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, optsList[0].RepoUserName, optsList[0].RepoName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRepositoryByOwnerAndName failed: %w", err)
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository[%s]: %w", repo.FullName(), err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
return fmt.Errorf("Failed to update size for repository: %v", err)
|
||||
}
|
||||
|
||||
addTags := make([]string, 0, len(optsList))
|
||||
delTags := make([]string, 0, len(optsList))
|
||||
var pusher *user_model.User
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
|
||||
for _, opts := range optsList {
|
||||
log.Trace("pushUpdates: %-v %s %s %s", repo, opts.OldCommitID, opts.NewCommitID, opts.RefFullName)
|
||||
|
||||
if opts.IsNewRef() && opts.IsDelRef() {
|
||||
return fmt.Errorf("old and new revisions are both %s", objectFormat.EmptyObjectID())
|
||||
}
|
||||
if opts.RefFullName.IsTag() {
|
||||
if pusher == nil || pusher.ID != opts.PusherID {
|
||||
if opts.PusherID == user_model.ActionsUserID {
|
||||
pusher = user_model.NewActionsUser()
|
||||
} else {
|
||||
var err error
|
||||
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
tagName := opts.RefFullName.TagName()
|
||||
if opts.IsDelRef() {
|
||||
notify_service.PushCommits(
|
||||
ctx, pusher, repo,
|
||||
&repo_module.PushUpdateOptions{
|
||||
RefFullName: git.RefNameFromTag(tagName),
|
||||
OldCommitID: opts.OldCommitID,
|
||||
NewCommitID: objectFormat.EmptyObjectID().String(),
|
||||
}, repo_module.NewPushCommits())
|
||||
|
||||
delTags = append(delTags, tagName)
|
||||
notify_service.DeleteRef(ctx, pusher, repo, opts.RefFullName)
|
||||
} else { // is new tag
|
||||
newCommit, err := gitRepo.GetCommit(opts.NewCommitID)
|
||||
if err != nil {
|
||||
// in case there is dirty data, for example, the "github.com/git/git" repository has tags pointing to non-existing commits
|
||||
if !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("Unable to get tag commit: gitRepo.GetCommit(%s) in %s/%s[%d]: %v", opts.NewCommitID, repo.OwnerName, repo.Name, repo.ID, err)
|
||||
}
|
||||
} else {
|
||||
commits := repo_module.NewPushCommits()
|
||||
commits.HeadCommit = repo_module.CommitToPushCommit(newCommit)
|
||||
commits.CompareURL = repo.ComposeCompareURL(objectFormat.EmptyObjectID().String(), opts.NewCommitID)
|
||||
|
||||
notify_service.PushCommits(
|
||||
ctx, pusher, repo,
|
||||
&repo_module.PushUpdateOptions{
|
||||
RefFullName: opts.RefFullName,
|
||||
OldCommitID: objectFormat.EmptyObjectID().String(),
|
||||
NewCommitID: opts.NewCommitID,
|
||||
}, commits)
|
||||
|
||||
addTags = append(addTags, tagName)
|
||||
notify_service.CreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID)
|
||||
}
|
||||
}
|
||||
} else if opts.RefFullName.IsBranch() {
|
||||
if pusher == nil || pusher.ID != opts.PusherID {
|
||||
if opts.PusherID == user_model.ActionsUserID {
|
||||
pusher = user_model.NewActionsUser()
|
||||
} else {
|
||||
var err error
|
||||
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.IsDelRef() {
|
||||
branch := opts.RefFullName.BranchName()
|
||||
|
||||
log.Trace("TriggerTask '%s/%s' by %s", repo.Name, branch, pusher.Name)
|
||||
|
||||
newCommit, err := gitRepo.GetCommit(opts.NewCommitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetCommit(%s) in %s/%s[%d]: %w", opts.NewCommitID, repo.OwnerName, repo.Name, repo.ID, err)
|
||||
}
|
||||
|
||||
// Push new branch.
|
||||
var l []*git.Commit
|
||||
if opts.IsNewRef() {
|
||||
l, err = pushNewBranch(ctx, repo, pusher, opts, newCommit)
|
||||
} else {
|
||||
l, err = pushUpdateBranch(ctx, repo, pusher, opts, newCommit)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete cache for divergence
|
||||
if branch == repo.DefaultBranch {
|
||||
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
||||
log.Error("DelRepoDivergenceFromCache: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
||||
log.Error("DelDivergenceFromCache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
commits := repo_module.GitToPushCommits(l)
|
||||
commits.HeadCommit = repo_module.CommitToPushCommit(newCommit)
|
||||
|
||||
if err := issue_service.UpdateIssuesCommit(ctx, pusher, repo, commits.Commits, opts.RefName()); err != nil {
|
||||
log.Error("updateIssuesCommit: %v", err)
|
||||
}
|
||||
|
||||
commits.CompareURL = getCompareURL(repo, gitRepo, objectFormat, commits.Commits, opts)
|
||||
|
||||
if len(commits.Commits) > setting.UI.FeedMaxCommitNum {
|
||||
commits.Commits = commits.Commits[:setting.UI.FeedMaxCommitNum]
|
||||
}
|
||||
|
||||
notify_service.PushCommits(ctx, pusher, repo, opts, commits)
|
||||
|
||||
// Cache for big repository
|
||||
if err := CacheRef(graceful.GetManager().HammerContext(), repo, gitRepo, opts.RefFullName); err != nil {
|
||||
log.Error("repo_module.CacheRef %s/%s failed: %v", repo.ID, branch, err)
|
||||
}
|
||||
} else {
|
||||
pushDeleteBranch(ctx, repo, pusher, opts)
|
||||
}
|
||||
|
||||
// Even if user delete a branch on a repository which he didn't watch, he will be watch that.
|
||||
if err = repo_model.WatchIfAuto(ctx, opts.PusherID, repo.ID, true); err != nil {
|
||||
log.Warn("Fail to perform auto watch on user %v for repo %v: %v", opts.PusherID, repo.ID, err)
|
||||
}
|
||||
} else {
|
||||
log.Trace("Non-tag and non-branch commits pushed.")
|
||||
}
|
||||
}
|
||||
|
||||
if len(addTags)+len(delTags) > 0 {
|
||||
if err := PushUpdateAddDeleteTags(ctx, repo, gitRepo, pusher, addTags, delTags); err != nil {
|
||||
return fmt.Errorf("PushUpdateAddDeleteTags: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Change repository last updated time.
|
||||
if err := repo_model.UpdateRepositoryUpdatedTime(ctx, repo.ID, time.Now()); err != nil {
|
||||
return fmt.Errorf("UpdateRepositoryUpdatedTime: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCompareURL(repo *repo_model.Repository, gitRepo *git.Repository, objectFormat git.ObjectFormat, commits []*repo_module.PushCommit, opts *repo_module.PushUpdateOptions) string {
|
||||
oldCommitID := opts.OldCommitID
|
||||
if oldCommitID == objectFormat.EmptyObjectID().String() && len(commits) > 0 {
|
||||
oldCommit, err := gitRepo.GetCommit(commits[len(commits)-1].Sha1)
|
||||
if err != nil && !git.IsErrNotExist(err) {
|
||||
log.Error("unable to GetCommit %s from %-v: %v", oldCommitID, repo, err)
|
||||
}
|
||||
if oldCommit != nil {
|
||||
for i := 0; i < oldCommit.ParentCount(); i++ {
|
||||
commitID, _ := oldCommit.ParentID(i)
|
||||
if !commitID.IsZero() {
|
||||
oldCommitID = commitID.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if oldCommitID == objectFormat.EmptyObjectID().String() && repo.DefaultBranch != opts.RefFullName.BranchName() {
|
||||
oldCommitID = repo.DefaultBranch
|
||||
}
|
||||
|
||||
if oldCommitID != objectFormat.EmptyObjectID().String() {
|
||||
return repo.ComposeCompareURL(oldCommitID, opts.NewCommitID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushNewBranch(ctx context.Context, repo *repo_model.Repository, pusher *user_model.User, opts *repo_module.PushUpdateOptions, newCommit *git.Commit) ([]*git.Commit, error) {
|
||||
if repo.IsEmpty { // Change default branch and empty status only if pushed ref is non-empty branch.
|
||||
repo.DefaultBranch = opts.RefName()
|
||||
repo.IsEmpty = false
|
||||
if repo.DefaultBranch != setting.Repository.DefaultBranch {
|
||||
if err := gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Update the is empty and default_branch columns
|
||||
if err := repo_model.UpdateRepositoryColsWithAutoTime(ctx, repo, "default_branch", "is_empty"); err != nil {
|
||||
return nil, fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
l, err := newCommit.CommitsBeforeLimit(10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newCommit.CommitsBeforeLimit: %w", err)
|
||||
}
|
||||
notify_service.CreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func pushUpdateBranch(_ context.Context, repo *repo_model.Repository, pusher *user_model.User, opts *repo_module.PushUpdateOptions, newCommit *git.Commit) ([]*git.Commit, error) {
|
||||
l, err := newCommit.CommitsBeforeUntil(opts.OldCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newCommit.CommitsBeforeUntil: %w", err)
|
||||
}
|
||||
|
||||
branch := opts.RefFullName.BranchName()
|
||||
|
||||
isForcePush, err := newCommit.IsForcePush(opts.OldCommitID)
|
||||
if err != nil {
|
||||
log.Error("IsForcePush %s:%s failed: %v", repo.FullName(), branch, err)
|
||||
}
|
||||
|
||||
// only update branch can trigger pull request task because the pull request hasn't been created yet when creating a branch
|
||||
go pull_service.AddTestPullRequestTask(pull_service.TestPullRequestOptions{
|
||||
RepoID: repo.ID,
|
||||
Doer: pusher,
|
||||
Branch: branch,
|
||||
IsSync: true,
|
||||
IsForcePush: isForcePush,
|
||||
OldCommitID: opts.OldCommitID,
|
||||
NewCommitID: opts.NewCommitID,
|
||||
})
|
||||
|
||||
if isForcePush {
|
||||
log.Trace("Push %s is a force push", opts.NewCommitID)
|
||||
|
||||
cache.Remove(repo.GetCommitsCountCacheKey(opts.RefName(), true))
|
||||
} else {
|
||||
// TODO: increment update the commit count cache but not remove
|
||||
cache.Remove(repo.GetCommitsCountCacheKey(opts.RefName(), true))
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func pushDeleteBranch(ctx context.Context, repo *repo_model.Repository, pusher *user_model.User, opts *repo_module.PushUpdateOptions) {
|
||||
notify_service.DeleteRef(ctx, pusher, repo, opts.RefFullName)
|
||||
|
||||
if err := pull_service.AdjustPullsCausedByBranchDeleted(ctx, pusher, repo, opts.RefFullName.BranchName()); err != nil {
|
||||
// close all related pulls
|
||||
log.Error("close related pull request failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PushUpdateAddDeleteTags updates a number of added and delete tags
|
||||
func PushUpdateAddDeleteTags(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, pusher *user_model.User, addTags, delTags []string) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := repo_model.PushUpdateDeleteTags(ctx, repo, delTags); err != nil {
|
||||
return err
|
||||
}
|
||||
return pushUpdateAddTags(ctx, repo, gitRepo, pusher, addTags)
|
||||
})
|
||||
}
|
||||
|
||||
// pushUpdateAddTags updates a number of add tags
|
||||
func pushUpdateAddTags(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, pusher *user_model.User, tags []string) error {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repo.ID,
|
||||
TagNames: tags,
|
||||
IncludeDrafts: true,
|
||||
IncludeTags: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.Find[repo_model.Release]: %w", err)
|
||||
}
|
||||
relMap := make(map[string]*repo_model.Release)
|
||||
for _, rel := range releases {
|
||||
relMap[rel.LowerTagName] = rel
|
||||
}
|
||||
|
||||
lowerTags := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
lowerTags = append(lowerTags, strings.ToLower(tag))
|
||||
}
|
||||
|
||||
newReleases := make([]*repo_model.Release, 0, len(lowerTags)-len(relMap))
|
||||
|
||||
for i, lowerTag := range lowerTags {
|
||||
tag, err := gitRepo.GetTag(tags[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTag: %w", err)
|
||||
}
|
||||
commit, err := gitRepo.GetTagCommit(tag.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Commit: %w", err)
|
||||
}
|
||||
|
||||
sig := tag.Tagger
|
||||
if sig == nil {
|
||||
sig = commit.Author
|
||||
}
|
||||
if sig == nil {
|
||||
sig = commit.Committer
|
||||
}
|
||||
|
||||
createdAt := time.Unix(1, 0)
|
||||
if sig != nil {
|
||||
createdAt = sig.When
|
||||
}
|
||||
|
||||
rel, has := relMap[lowerTag]
|
||||
title, note := git.SplitCommitTitleBody(tag.MessageUTF8(), 255)
|
||||
if !has {
|
||||
rel = &repo_model.Release{
|
||||
RepoID: repo.ID,
|
||||
Title: title,
|
||||
TagName: tags[i],
|
||||
LowerTagName: lowerTag,
|
||||
Target: "",
|
||||
Sha1: commit.ID.String(),
|
||||
NumCommits: -1, // the commits count will be updated when the UI needs it
|
||||
Note: note,
|
||||
IsDraft: false,
|
||||
IsPrerelease: false,
|
||||
IsTag: true,
|
||||
PublisherID: pusher.ID,
|
||||
CreatedUnix: timeutil.TimeStamp(createdAt.Unix()),
|
||||
}
|
||||
|
||||
newReleases = append(newReleases, rel)
|
||||
} else {
|
||||
rel.Sha1 = commit.ID.String()
|
||||
rel.CreatedUnix = timeutil.TimeStamp(createdAt.Unix())
|
||||
if rel.IsTag {
|
||||
rel.Title = title
|
||||
rel.Note = note
|
||||
} else {
|
||||
rel.IsDraft = false
|
||||
}
|
||||
rel.PublisherID = pusher.ID
|
||||
if err = repo_model.UpdateRelease(ctx, rel); err != nil {
|
||||
return fmt.Errorf("Update: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(newReleases) > 0 {
|
||||
if err = db.Insert(ctx, newReleases); err != nil {
|
||||
return fmt.Errorf("Insert: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// TeamAddRepository adds new repository to team of organization.
|
||||
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
|
||||
if repo.OwnerID != t.OrgID {
|
||||
return errors.New("repository does not belong to organization")
|
||||
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return addRepositoryToTeam(ctx, t, repo)
|
||||
})
|
||||
}
|
||||
|
||||
func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
|
||||
if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil {
|
||||
return fmt.Errorf("update team: %w", err)
|
||||
}
|
||||
|
||||
t.NumRepos++
|
||||
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
||||
return fmt.Errorf("recalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
// Make all team members watch this repo if enabled in global settings
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
if err = t.LoadMembers(ctx); err != nil {
|
||||
return fmt.Errorf("getMembers: %w", err)
|
||||
}
|
||||
for _, u := range t.Members {
|
||||
if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil {
|
||||
return fmt.Errorf("watchRepo: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddAllRepositoriesToTeam adds all repositories to the team.
|
||||
// If the team already has some repositories they will be left unchanged.
|
||||
func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
orgRepos, err := repo_model.GetOrgRepositories(ctx, t.OrgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get org repos: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range orgRepos {
|
||||
if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveAllRepositoriesFromTeam removes all repositories from team and recalculates access
|
||||
func RemoveAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
|
||||
if t.IncludesAllRepositories {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return removeAllRepositoriesFromTeam(ctx, t)
|
||||
})
|
||||
}
|
||||
|
||||
// removeAllRepositoriesFromTeam removes all repositories from team and recalculates access
|
||||
// Note: Shall not be called if team includes all repositories
|
||||
func removeAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
repos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
||||
TeamID: t.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTeamRepositories: %w", err)
|
||||
}
|
||||
|
||||
// Delete all accesses.
|
||||
for _, repo := range repos {
|
||||
if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove watches from all users and now inaccessible repos
|
||||
for _, user := range t.Members {
|
||||
has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repositories
|
||||
if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete team-repo
|
||||
if _, err := e.
|
||||
Where("team_id=?", t.ID).
|
||||
Delete(new(organization.TeamRepo)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.NumRepos = 0
|
||||
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRepositoryFromTeam removes repository from team of organization.
|
||||
// If the team shall include all repositories the request is ignored.
|
||||
func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error {
|
||||
if !HasRepository(ctx, t, repoID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.IncludesAllRepositories {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return removeRepositoryFromTeam(ctx, t, repo, true)
|
||||
})
|
||||
}
|
||||
|
||||
// removeRepositoryFromTeam removes a repository from a team and recalculates access
|
||||
// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted)
|
||||
func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.NumRepos--
|
||||
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't need to recalculate when delete a repository from organization.
|
||||
if recalculate {
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
TeamID: t.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTeamMembers: %w", err)
|
||||
}
|
||||
for _, member := range teamMembers {
|
||||
has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repositories
|
||||
if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRepository returns true if given repository belong to team.
|
||||
func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool {
|
||||
return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTeam_AddRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(teamID, repoID int64) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.NoError(t, TeamAddRepository(t.Context(), team, repo))
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID})
|
||||
}
|
||||
testSuccess(2, 3)
|
||||
testSuccess(2, 5)
|
||||
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.Error(t, TeamAddRepository(t.Context(), team, repo))
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: 1}, &repo_model.Repository{ID: 1})
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
issue_indexer "gitea.dev/modules/indexer/issues"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
// WebSearchRepository represents a repository returned by web search
|
||||
type WebSearchRepository struct {
|
||||
Repository *structs.Repository `json:"repository"`
|
||||
LatestCommitStatus *git.CommitStatus `json:"latest_commit_status"`
|
||||
LocaleLatestCommitStatus string `json:"locale_latest_commit_status"`
|
||||
}
|
||||
|
||||
// WebSearchResults results of a successful web search
|
||||
type WebSearchResults struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []*WebSearchRepository `json:"data"`
|
||||
}
|
||||
|
||||
// CreateRepository creates a repository for the user/organization.
|
||||
func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) {
|
||||
repo, err := CreateRepositoryDirectly(ctx, doer, owner, opts, true)
|
||||
if err != nil {
|
||||
// No need to rollback here we should do this in CreateRepository...
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notify_service.CreateRepository(ctx, doer, owner, repo)
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// DeleteRepository deletes a repository for a user or organization.
|
||||
func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, notify bool) error {
|
||||
if err := pull_service.CloseRepoBranchesPulls(ctx, doer, repo); err != nil {
|
||||
log.Error("CloseRepoBranchesPulls failed: %v", err)
|
||||
}
|
||||
|
||||
if notify {
|
||||
// If the repo itself has webhooks, we need to trigger them before deleting it...
|
||||
notify_service.DeleteRepository(ctx, doer, repo)
|
||||
}
|
||||
|
||||
return DeleteRepositoryDirectly(ctx, repo.ID)
|
||||
}
|
||||
|
||||
// PushCreateRepo creates a repository when a new repository is pushed to an appropriate namespace
|
||||
func PushCreateRepo(ctx context.Context, authUser, owner *user_model.User, repoName string) (*repo_model.Repository, error) {
|
||||
if !authUser.IsAdmin {
|
||||
if owner.IsOrganization() {
|
||||
if ok, err := organization.CanCreateOrgRepo(ctx, owner.ID, authUser.ID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, errors.New("cannot push-create repository for org")
|
||||
}
|
||||
} else if authUser.ID != owner.ID {
|
||||
return nil, errors.New("cannot push-create repository for another user")
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := CreateRepository(ctx, authUser, owner, CreateRepoOptions{
|
||||
Name: repoName,
|
||||
IsPrivate: setting.Repository.DefaultPushCreatePrivate || setting.Repository.ForcePrivate,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// Init start repository service
|
||||
func Init(ctx context.Context) error {
|
||||
licenseUpdaterQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "repo_license_updater", repoLicenseUpdater)
|
||||
if licenseUpdaterQueue == nil {
|
||||
return errors.New("unable to create repo_license_updater queue")
|
||||
}
|
||||
go graceful.GetManager().RunWithCancel(licenseUpdaterQueue)
|
||||
|
||||
if err := repo_module.LoadRepoConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initPushQueue(); err != nil {
|
||||
return err
|
||||
}
|
||||
return initBranchSyncQueue(graceful.GetManager().ShutdownContext())
|
||||
}
|
||||
|
||||
// UpdateRepository updates a repository
|
||||
func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibilityChanged bool) (err error) {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err = updateRepository(ctx, repo, visibilityChanged); err != nil {
|
||||
return fmt.Errorf("updateRepository: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository, private bool) (err error) {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repo.IsPrivate = private
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
if repo.Owner.IsOrganization() {
|
||||
// Organization repository need to recalculate access table when visibility is changed.
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
||||
return fmt.Errorf("recalculateTeamAccesses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create/Remove git-daemon-export-ok for git-daemon...
|
||||
if err := CheckDaemonExportOK(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If repo has become private, we need to set its actions to private, and clear stars and watches.
|
||||
if private {
|
||||
_, err = db.GetEngine(ctx).
|
||||
Where("repo_id = ?", repo.ID).Cols("is_private").Update(&activities_model.Action{IsPrivate: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shouldUpdateForks := private
|
||||
if !private && repo.Owner.Visibility != structs.VisibleTypePrivate {
|
||||
shouldUpdateForks = true
|
||||
}
|
||||
if shouldUpdateForks {
|
||||
forkRepos, err := repo_model.GetRepositoriesByForkID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getRepositoriesByForkID: %w", err)
|
||||
}
|
||||
for _, forkRepo := range forkRepos {
|
||||
if err = MakeRepoPrivate(ctx, forkRepo, private); err != nil {
|
||||
return fmt.Errorf("MakeRepoPrivate[%d]: %w", forkRepo.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If visibility is changed, we need to update the issue indexer.
|
||||
// Since the data in the issue indexer have field to indicate if the repo is public or not.
|
||||
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetAttachmentLinkedTypeAndRepoID returns the linked type and repository id of attachment if any
|
||||
func GetAttachmentLinkedTypeAndRepoID(ctx context.Context, a *repo_model.Attachment) (unit.Type, int64, error) {
|
||||
if a.IssueID != 0 {
|
||||
iss, err := issues_model.GetIssueByID(ctx, a.IssueID)
|
||||
if err != nil {
|
||||
return unit.TypeIssues, 0, err
|
||||
}
|
||||
unitType := unit.TypeIssues
|
||||
if iss.IsPull {
|
||||
unitType = unit.TypePullRequests
|
||||
}
|
||||
return unitType, iss.RepoID, nil
|
||||
}
|
||||
|
||||
if a.ReleaseID != 0 {
|
||||
rel, err := repo_model.GetReleaseByID(ctx, a.ReleaseID)
|
||||
if err != nil {
|
||||
return unit.TypeReleases, 0, err
|
||||
}
|
||||
return unit.TypeReleases, rel.RepoID, nil
|
||||
}
|
||||
return unit.TypeInvalid, 0, nil
|
||||
}
|
||||
|
||||
// CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon...
|
||||
func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create/Remove git-daemon-export-ok for git-daemon...
|
||||
daemonExportFile := `git-daemon-export-ok`
|
||||
isExist, err := gitrepo.IsRepoFileExist(ctx, repo, daemonExportFile)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", daemonExportFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
isPublic := !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic
|
||||
if !isPublic && isExist {
|
||||
if err = gitrepo.RemoveRepoFileOrDir(ctx, repo, daemonExportFile); err != nil {
|
||||
log.Error("Failed to remove %s: %v", daemonExportFile, err)
|
||||
}
|
||||
} else if isPublic && !isExist {
|
||||
if f, err := gitrepo.CreateRepoFile(ctx, repo, daemonExportFile); err != nil {
|
||||
log.Error("Failed to create %s: %v", daemonExportFile, err)
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRepository updates a repository with db context
|
||||
func updateRepository(ctx context.Context, repo *repo_model.Repository, visibilityChanged bool) (err error) {
|
||||
repo.LowerName = strings.ToLower(repo.Name)
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
if _, err = e.ID(repo.ID).NoAutoTime().AllCols().Update(repo); err != nil {
|
||||
return fmt.Errorf("update: %w", err)
|
||||
}
|
||||
|
||||
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil {
|
||||
log.Error("Failed to update size for repository: %v", err)
|
||||
}
|
||||
|
||||
if visibilityChanged {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
if repo.Owner.IsOrganization() {
|
||||
// Organization repository need to recalculate access table when visibility is changed.
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
||||
return fmt.Errorf("recalculateTeamAccesses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If repo has become private, we need to set its actions to private.
|
||||
if repo.IsPrivate {
|
||||
_, err = e.Where("repo_id = ?", repo.ID).Cols("is_private").Update(&activities_model.Action{
|
||||
IsPrivate: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create/Remove git-daemon-export-ok for git-daemon...
|
||||
if err := CheckDaemonExportOK(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
forkRepos, err := repo_model.GetRepositoriesByForkID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getRepositoriesByForkID: %w", err)
|
||||
}
|
||||
for i := range forkRepos {
|
||||
forkRepos[i].IsPrivate = repo.IsPrivate || repo.Owner.Visibility == structs.VisibleTypePrivate
|
||||
if err = updateRepository(ctx, forkRepos[i], true); err != nil {
|
||||
return fmt.Errorf("updateRepository[%d]: %w", forkRepos[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// If visibility is changed, we need to update the issue indexer.
|
||||
// Since the data in the issue indexer have field to indicate if the repo is public or not.
|
||||
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func HasWiki(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
hasWiki, err := gitrepo.IsRepositoryExist(ctx, repo.WikiStorageRepo())
|
||||
if err != nil {
|
||||
log.Error("gitrepo.IsRepositoryExist: %v", err)
|
||||
}
|
||||
return hasWiki && err == nil
|
||||
}
|
||||
|
||||
// CheckCreateRepository check if doer could create a repository in new owner
|
||||
func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return repo_model.ErrReachLimitOfRepo{Limit: owner.MaxRepoCreation}
|
||||
}
|
||||
|
||||
if err := repo_model.IsUsableRepoName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, owner, name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{Uname: owner.Name, Name: name}
|
||||
}
|
||||
repo := repo_model.StorageRepo(repo_model.RelativePath(owner.Name, name))
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repo.RelativePath(), err)
|
||||
return err
|
||||
}
|
||||
if !overwriteOrAdopt && isExist {
|
||||
return repo_model.ErrRepoFilesAlreadyExist{Uname: owner.Name, Name: name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAttachLinkedTypeAndRepoID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
testCases := []struct {
|
||||
name string
|
||||
attachID int64
|
||||
expectedUnitType unit.Type
|
||||
expectedRepoID int64
|
||||
}{
|
||||
{"LinkedIssue", 1, unit.TypeIssues, 1},
|
||||
{"LinkedComment", 3, unit.TypePullRequests, 1},
|
||||
{"LinkedRelease", 9, unit.TypeReleases, 1},
|
||||
{"Notlinked", 10, unit.TypeInvalid, 0},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attach, err := repo_model.GetAttachmentByID(t.Context(), tc.attachID)
|
||||
assert.NoError(t, err)
|
||||
unitType, repoID, err := GetAttachmentLinkedTypeAndRepoID(t.Context(), attach)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedUnitType, unitType)
|
||||
assert.Equal(t, tc.expectedRepoID, repoID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRepositoryVisibilityChanged(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Get sample repo and change visibility
|
||||
repo, err := repo_model.GetRepositoryByID(t.Context(), 9)
|
||||
assert.NoError(t, err)
|
||||
repo.IsPrivate = true
|
||||
|
||||
// Update it
|
||||
err = updateRepository(t.Context(), repo, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check visibility of action has become private
|
||||
act := activities_model.Action{}
|
||||
_, err = db.GetEngine(t.Context()).ID(3).Get(&act)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, act.IsPrivate)
|
||||
}
|
||||
|
||||
func TestRepository_HasWiki(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.True(t, HasWiki(t.Context(), repo1))
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.False(t, HasWiki(t.Context(), repo2))
|
||||
}
|
||||
|
||||
func TestMakeRepoPrivateClearsWatches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.False(t, repo.IsPrivate)
|
||||
|
||||
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, watchers)
|
||||
|
||||
assert.NoError(t, MakeRepoPrivate(t.Context(), repo, true))
|
||||
|
||||
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
|
||||
updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.True(t, updatedRepo.IsPrivate)
|
||||
assert.Zero(t, updatedRepo.NumWatches)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/log"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
)
|
||||
|
||||
// UpdateRepositoryUnits updates a repository's units
|
||||
func UpdateRepositoryUnits(ctx context.Context, repo *repo_model.Repository, units []repo_model.RepoUnit, deleteUnitTypes []unit.Type) (err error) {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Delete existing settings of units before adding again
|
||||
for _, u := range units {
|
||||
deleteUnitTypes = append(deleteUnitTypes, u.Type)
|
||||
}
|
||||
|
||||
if slices.Contains(deleteUnitTypes, unit.TypeActions) {
|
||||
if err := actions_service.CleanRepoScheduleTasks(ctx, repo); err != nil {
|
||||
log.Error("CleanRepoScheduleTasks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range units {
|
||||
if u.Type == unit.TypeActions {
|
||||
if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil {
|
||||
log.Error("DetectAndHandleSchedules: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = db.GetEngine(ctx).Where("repo_id = ?", repo.ID).In("type", deleteUnitTypes).Delete(new(repo_model.RepoUnit)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(units) > 0 {
|
||||
if err = db.Insert(ctx, units); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// GenerateIssueLabels generates issue labels from a template repository
|
||||
func GenerateIssueLabels(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
templateLabels, err := issues_model.GetLabelsByRepoID(ctx, templateRepo.ID, "", db.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prevent insert being called with an empty slice which would result in
|
||||
// err "no element on slice when insert".
|
||||
if len(templateLabels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newLabels := make([]*issues_model.Label, 0, len(templateLabels))
|
||||
for _, templateLabel := range templateLabels {
|
||||
newLabels = append(newLabels, &issues_model.Label{
|
||||
RepoID: generateRepo.ID,
|
||||
Name: templateLabel.Name,
|
||||
Exclusive: templateLabel.Exclusive,
|
||||
ExclusiveOrder: templateLabel.ExclusiveOrder,
|
||||
Description: templateLabel.Description,
|
||||
Color: templateLabel.Color,
|
||||
})
|
||||
}
|
||||
return db.Insert(ctx, newLabels)
|
||||
}
|
||||
|
||||
func GenerateProtectedBranch(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
|
||||
templateBranches, err := git_model.FindRepoProtectedBranchRules(ctx, templateRepo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prevent insert being called with an empty slice which would result in
|
||||
// err "no element on slice when insert".
|
||||
if len(templateBranches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newBranches := make([]*git_model.ProtectedBranch, 0, len(templateBranches))
|
||||
for _, templateBranch := range templateBranches {
|
||||
templateBranch.ID = 0
|
||||
templateBranch.RepoID = generateRepo.ID
|
||||
templateBranch.UpdatedUnix = 0
|
||||
templateBranch.CreatedUnix = 0
|
||||
newBranches = append(newBranches, templateBranch)
|
||||
}
|
||||
return db.Insert(ctx, newBranches)
|
||||
}
|
||||
|
||||
// GenerateRepository generates a repository from a template
|
||||
func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return nil, repo_model.ErrReachLimitOfRepo{
|
||||
Limit: owner.MaxRepoCreation,
|
||||
}
|
||||
}
|
||||
|
||||
generateRepo := &repo_model.Repository{
|
||||
OwnerID: owner.ID,
|
||||
Owner: owner,
|
||||
OwnerName: owner.Name,
|
||||
Name: opts.Name,
|
||||
LowerName: strings.ToLower(opts.Name),
|
||||
Description: opts.Description,
|
||||
DefaultBranch: opts.DefaultBranch,
|
||||
IsPrivate: opts.Private,
|
||||
IsEmpty: !opts.GitContent || templateRepo.IsEmpty,
|
||||
IsFsckEnabled: templateRepo.IsFsckEnabled,
|
||||
TemplateID: templateRepo.ID,
|
||||
TrustModel: templateRepo.TrustModel,
|
||||
ObjectFormatName: templateRepo.ObjectFormatName,
|
||||
Status: repo_model.RepositoryBeingMigrated,
|
||||
}
|
||||
|
||||
// 1 - Create the repository in the database
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return createRepositoryInDB(ctx, doer, owner, generateRepo, false)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// last - clean up the repository if something goes wrong
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// we can not use `ctx` because it may be canceled or timed out
|
||||
cleanupRepository(generateRepo)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2 - check whether the repository with the same storage exists
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, generateRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", generateRepo.FullName(), err)
|
||||
return nil, err
|
||||
}
|
||||
if isExist {
|
||||
// Don't return directly, we need err in defer to cleanupRepository
|
||||
err = repo_model.ErrRepoFilesAlreadyExist{
|
||||
Uname: generateRepo.OwnerName,
|
||||
Name: generateRepo.Name,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3 -Init git bare new repository.
|
||||
if err = gitrepo.InitRepository(ctx, generateRepo, generateRepo.ObjectFormatName); err != nil {
|
||||
return nil, fmt.Errorf("git.InitRepository: %w", err)
|
||||
} else if err = gitrepo.CreateDelegateHooks(ctx, generateRepo); err != nil {
|
||||
return nil, fmt.Errorf("createDelegateHooks: %w", err)
|
||||
}
|
||||
|
||||
// 4 - Update the git repository
|
||||
if err = updateGitRepoAfterCreate(ctx, generateRepo); err != nil {
|
||||
return nil, fmt.Errorf("updateGitRepoAfterCreate: %w", err)
|
||||
}
|
||||
|
||||
// 5 - generate the repository contents according to the template
|
||||
// Git Content
|
||||
if opts.GitContent && !templateRepo.IsEmpty {
|
||||
if err = GenerateGitContent(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Topics
|
||||
if opts.Topics {
|
||||
if err = repo_model.GenerateTopics(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Git Hooks
|
||||
if opts.GitHooks {
|
||||
if err = GenerateGitHooks(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
if opts.Webhooks {
|
||||
if err = GenerateWebhooks(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar
|
||||
if opts.Avatar && len(templateRepo.Avatar) > 0 {
|
||||
if err = generateAvatar(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Issue Labels
|
||||
if opts.IssueLabels {
|
||||
if err = GenerateIssueLabels(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ProtectedBranch {
|
||||
if err = GenerateProtectedBranch(ctx, templateRepo, generateRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 6 - update repository status to be ready
|
||||
generateRepo.Status = repo_model.RepositoryReady
|
||||
if err = repo_model.UpdateRepositoryColsWithAutoTime(ctx, generateRepo, "status"); err != nil {
|
||||
return nil, fmt.Errorf("UpdateRepositoryCols: %w", err)
|
||||
}
|
||||
|
||||
notify_service.CreateRepository(ctx, doer, owner, generateRepo)
|
||||
|
||||
return generateRepo, nil
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
type LimitReachedError struct{ Limit int }
|
||||
|
||||
func (LimitReachedError) Error() string {
|
||||
return "Repository limit has been reached"
|
||||
}
|
||||
|
||||
func IsRepositoryLimitReached(err error) bool {
|
||||
_, ok := err.(LimitReachedError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func getRepoWorkingLockKey(repoID int64) string {
|
||||
return fmt.Sprintf("repo_working_%d", repoID)
|
||||
}
|
||||
|
||||
// AcceptTransferOwnership transfers all corresponding setting from old user to new one.
|
||||
func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, doer *user_model.User) error {
|
||||
releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldOwnerName := repo.OwnerName
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := repoTransfer.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !doer.CanCreateRepoIn(repoTransfer.Recipient) {
|
||||
return LimitReachedError{Limit: repoTransfer.Recipient.MaxCreationLimit()}
|
||||
}
|
||||
|
||||
if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, team := range repoTransfer.Teams {
|
||||
if repoTransfer.Recipient.ID != team.OrgID {
|
||||
return fmt.Errorf("team %d does not belong to organization", team.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return transferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient.Name, repo, repoTransfer.Teams)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
releaser()
|
||||
|
||||
notify_service.TransferRepository(ctx, doer, repo, oldOwnerName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isRepositoryModelOrDirExist returns true if the repository with given name under user has already existed.
|
||||
func isRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, u, repoName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
repo := repo_model.StorageRepo(repo_model.RelativePath(u.Name, repoName))
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
return has || isExist, err
|
||||
}
|
||||
|
||||
// transferOwnership transfers all corresponding repository items from old user to new one.
|
||||
func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName string, repo *repo_model.Repository, teams []*organization.Team) (err error) {
|
||||
repoRenamed := false
|
||||
wikiRenamed := false
|
||||
oldOwnerName := doer.Name
|
||||
|
||||
defer func() {
|
||||
if !repoRenamed && !wikiRenamed {
|
||||
return
|
||||
}
|
||||
|
||||
recoverErr := recover()
|
||||
if err == nil && recoverErr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if repoRenamed {
|
||||
oldRelativePath, newRelativePath := repo_model.RelativePath(newOwnerName, repo.Name), repo_model.RelativePath(oldOwnerName, repo.Name)
|
||||
if err := gitrepo.RenameRepository(ctx, repo_model.StorageRepo(oldRelativePath), repo_model.StorageRepo(newRelativePath)); err != nil {
|
||||
log.Error("Unable to move repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
|
||||
oldRelativePath, newRelativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
if wikiRenamed {
|
||||
oldRelativePath, newRelativePath := repo_model.RelativeWikiPath(newOwnerName, repo.Name), repo_model.RelativeWikiPath(oldOwnerName, repo.Name)
|
||||
if err := gitrepo.RenameRepository(ctx, repo_model.StorageRepo(oldRelativePath), repo_model.StorageRepo(newRelativePath)); err != nil {
|
||||
log.Error("Unable to move wiki for repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
|
||||
oldRelativePath, newRelativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
if recoverErr != nil {
|
||||
log.Error("Panic within TransferOwnership: %v\n%s", recoverErr, log.Stack(2))
|
||||
panic(recoverErr)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
newOwner, err := user_model.GetUserByName(ctx, newOwnerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get new owner '%s': %w", newOwnerName, err)
|
||||
}
|
||||
newOwnerName = newOwner.Name // ensure capitalisation matches
|
||||
|
||||
// Check if new owner has repository with same name.
|
||||
if has, err := isRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: newOwnerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
oldOwner := repo.Owner
|
||||
oldOwnerName = oldOwner.Name
|
||||
|
||||
// Note: we have to set value here to make sure recalculate accesses is based on
|
||||
// new owner.
|
||||
repo.OwnerID = newOwner.ID
|
||||
repo.Owner = newOwner
|
||||
repo.OwnerName = newOwner.Name
|
||||
|
||||
// Update repository.
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "owner_id", "owner_name"); err != nil {
|
||||
return fmt.Errorf("update owner: %w", err)
|
||||
}
|
||||
|
||||
// Remove redundant collaborators.
|
||||
collaborators, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: repo.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetCollaborators: %w", err)
|
||||
}
|
||||
|
||||
// Dummy object.
|
||||
collaboration := &repo_model.Collaboration{RepoID: repo.ID}
|
||||
for _, c := range collaborators {
|
||||
if c.IsGhost() {
|
||||
collaboration.ID = c.Collaboration.ID
|
||||
if _, err := sess.Delete(collaboration); err != nil {
|
||||
return fmt.Errorf("remove collaborator '%d': %w", c.ID, err)
|
||||
}
|
||||
collaboration.ID = 0
|
||||
}
|
||||
|
||||
if c.ID != newOwner.ID {
|
||||
isMember, err := organization.IsOrganizationMember(ctx, newOwner.ID, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsOrgMember: %w", err)
|
||||
} else if !isMember {
|
||||
continue
|
||||
}
|
||||
}
|
||||
collaboration.UserID = c.ID
|
||||
if _, err := sess.Delete(collaboration); err != nil {
|
||||
return fmt.Errorf("remove collaborator '%d': %w", c.ID, err)
|
||||
}
|
||||
collaboration.UserID = 0
|
||||
}
|
||||
|
||||
if oldOwner.IsOrganization() {
|
||||
// Remove old team-repository relations.
|
||||
if err := organization.RemoveOrgRepo(ctx, oldOwner.ID, repo.ID); err != nil {
|
||||
return fmt.Errorf("removeOrgRepo: %w", err)
|
||||
}
|
||||
|
||||
// Remove project's issues that belong to old organization's projects
|
||||
projects, err := project_model.GetAllProjectsIDsByOwnerIDAndType(ctx, oldOwner.ID, project_model.TypeOrganization)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to find old org projects: %w", err)
|
||||
}
|
||||
issues, err := issues_model.GetIssueIDsByRepoID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to find repo's issues: %w", err)
|
||||
}
|
||||
err = project_model.DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx, issues, projects)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to delete project's issues: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if newOwner.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, newOwner.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LoadTeams: %w", err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
// Organization called this in addRepository method.
|
||||
return fmt.Errorf("recalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
// Remove repository from old owner's Actions AllowedCrossRepoIDs if present
|
||||
if oldActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, oldOwner.ID); err == nil {
|
||||
newAllowedCrossRepoIDs := util.SliceRemoveAll(oldActionsCfg.AllowedCrossRepoIDs, repo.ID)
|
||||
if len(newAllowedCrossRepoIDs) != len(oldActionsCfg.AllowedCrossRepoIDs) {
|
||||
oldActionsCfg.AllowedCrossRepoIDs = newAllowedCrossRepoIDs
|
||||
if err := actions_model.SetOwnerActionsConfig(ctx, oldOwner.ID, oldActionsCfg); err != nil {
|
||||
return fmt.Errorf("SetOwnerActionsConfig: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("GetOwnerActionsConfig: %w", err)
|
||||
}
|
||||
|
||||
// Update repository count.
|
||||
if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
|
||||
return fmt.Errorf("increase new owner repository count: %w", err)
|
||||
} else if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil {
|
||||
return fmt.Errorf("decrease old owner repository count: %w", err)
|
||||
}
|
||||
|
||||
if err := repo_model.WatchRepo(ctx, doer, repo, true); err != nil {
|
||||
return fmt.Errorf("watchRepo: %w", err)
|
||||
}
|
||||
|
||||
if oldOwner.IsOrganization() {
|
||||
// Remove watch for organization.
|
||||
if err := repo_model.WatchRepo(ctx, oldOwner, repo, false); err != nil {
|
||||
return fmt.Errorf("watchRepo [false]: %w", err)
|
||||
}
|
||||
|
||||
// Delete labels that belong to the old organization and comments that added these labels
|
||||
if _, err := sess.Exec(`DELETE FROM issue_label WHERE issue_label.id IN (
|
||||
SELECT il_too.id FROM (
|
||||
SELECT il_too_too.id
|
||||
FROM issue_label AS il_too_too
|
||||
INNER JOIN label ON il_too_too.label_id = label.id
|
||||
INNER JOIN issue on issue.id = il_too_too.issue_id
|
||||
WHERE
|
||||
issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?))
|
||||
) AS il_too )`, repo.ID, newOwner.ID); err != nil {
|
||||
return fmt.Errorf("Unable to remove old org labels: %w", err)
|
||||
}
|
||||
|
||||
if _, err := sess.Exec(`DELETE FROM comment WHERE comment.id IN (
|
||||
SELECT il_too.id FROM (
|
||||
SELECT com.id
|
||||
FROM comment AS com
|
||||
INNER JOIN label ON com.label_id = label.id
|
||||
INNER JOIN issue ON issue.id = com.issue_id
|
||||
WHERE
|
||||
com.type = ? AND issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?))
|
||||
) AS il_too)`, issues_model.CommentTypeLabel, repo.ID, newOwner.ID); err != nil {
|
||||
return fmt.Errorf("Unable to remove old org label comments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename remote repository to new path and delete local copy.
|
||||
oldRelativePath, newRelativePath := repo_model.RelativePath(oldOwner.Name, repo.Name), repo_model.RelativePath(newOwner.Name, repo.Name)
|
||||
if err := gitrepo.RenameRepository(ctx, repo_model.StorageRepo(oldRelativePath), repo_model.StorageRepo(newRelativePath)); err != nil {
|
||||
return fmt.Errorf("rename repository directory: %w", err)
|
||||
}
|
||||
repoRenamed = true
|
||||
|
||||
// Rename remote wiki repository to new path and delete local copy.
|
||||
wikiStorageRepo := repo_model.StorageRepo(repo_model.RelativeWikiPath(oldOwner.Name, repo.Name))
|
||||
if isExist, err := gitrepo.IsRepositoryExist(ctx, wikiStorageRepo); err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", wikiStorageRepo.RelativePath(), err)
|
||||
return err
|
||||
} else if isExist {
|
||||
if err := gitrepo.RenameRepository(ctx, wikiStorageRepo, repo_model.StorageRepo(repo_model.RelativeWikiPath(newOwner.Name, repo.Name))); err != nil {
|
||||
return fmt.Errorf("rename repository wiki: %w", err)
|
||||
}
|
||||
wikiRenamed = true
|
||||
}
|
||||
|
||||
if err := repo_model.DeleteRepositoryTransfer(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("deleteRepositoryTransfer: %w", err)
|
||||
}
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If there was previously a redirect at this location, remove it.
|
||||
if err := repo_model.DeleteRedirect(ctx, newOwner.ID, repo.Name); err != nil {
|
||||
return fmt.Errorf("delete repo redirect: %w", err)
|
||||
}
|
||||
|
||||
if err := repo_model.NewRedirect(ctx, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil {
|
||||
return fmt.Errorf("repo_model.NewRedirect: %w", err)
|
||||
}
|
||||
|
||||
newRepo, err := repo_model.GetRepositoryByID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if err := addRepositoryToTeam(ctx, team, newRepo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// changeRepositoryName changes all corresponding setting from old repository name to new one.
|
||||
func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newRepoName string) (err error) {
|
||||
oldRepoName := repo.Name
|
||||
newRepoName = strings.ToLower(newRepoName)
|
||||
if err = repo_model.IsUsableRepoName(newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := isRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: repo.OwnerName,
|
||||
Name: newRepoName,
|
||||
}
|
||||
}
|
||||
|
||||
if err = gitrepo.RenameRepository(ctx, repo,
|
||||
repo_model.StorageRepo(repo_model.RelativePath(repo.OwnerName, newRepoName))); err != nil {
|
||||
return fmt.Errorf("rename repository directory: %w", err)
|
||||
}
|
||||
|
||||
if HasWiki(ctx, repo) {
|
||||
if err = gitrepo.RenameRepository(ctx, repo.WikiStorageRepo(), repo_model.StorageRepo(
|
||||
repo_model.RelativeWikiPath(repo.OwnerName, newRepoName))); err != nil {
|
||||
return fmt.Errorf("rename repository wiki: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return repo_model.NewRedirect(ctx, repo.Owner.ID, repo.ID, oldRepoName, newRepoName)
|
||||
})
|
||||
}
|
||||
|
||||
// ChangeRepositoryName changes all corresponding setting from old repository name to new one.
|
||||
func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newRepoName string) error {
|
||||
log.Trace("ChangeRepositoryName: %s/%s -> %s", doer.Name, repo.Name, newRepoName)
|
||||
|
||||
oldRepoName := repo.Name
|
||||
|
||||
// Change repository directory name. We must lock the local copy of the
|
||||
// repo so that we can automatically rename the repo path and updates the
|
||||
// local copy's origin accordingly.
|
||||
|
||||
releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
if err := changeRepositoryName(ctx, repo, newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
releaser()
|
||||
|
||||
repo.Name = newRepoName
|
||||
notify_service.RenameRepository(ctx, doer, repo, oldRepoName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartRepositoryTransfer transfer a repo from one owner to a new one.
|
||||
// it make repository into pending transfer state, if doer can not create repo for new owner.
|
||||
func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository, teams []*organization.Team) error {
|
||||
releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
if err := repo_model.TestRepositoryReadyForTransfer(repo.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !doer.CanForkRepoIn(newOwner) {
|
||||
return LimitReachedError{Limit: newOwner.MaxCreationLimit()}
|
||||
}
|
||||
|
||||
var isDirectTransfer bool
|
||||
oldOwnerName := repo.OwnerName
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Admin is always allowed to transfer || user transfer repo back to his account,
|
||||
// then it will transfer directly without acceptance.
|
||||
if doer.IsAdmin || doer.ID == newOwner.ID {
|
||||
isDirectTransfer = true
|
||||
return transferOwnership(ctx, doer, newOwner.Name, repo, teams)
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, newOwner.ID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
// If new owner is an org and user can create repos he can transfer directly too
|
||||
if newOwner.IsOrganization() {
|
||||
allowed, err := organization.CanCreateOrgRepo(ctx, newOwner.ID, doer.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if allowed {
|
||||
isDirectTransfer = true
|
||||
return transferOwnership(ctx, doer, newOwner.Name, repo, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// In case the new owner would not have sufficient access to the repo, give access rights for read
|
||||
hasAccess, err := access_model.HasAnyUnitAccess(ctx, newOwner.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
if err := AddOrUpdateCollaborator(ctx, repo, newOwner, perm.AccessModeRead); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Make repo as pending for transfer
|
||||
repo.Status = repo_model.RepositoryPendingTransfer
|
||||
return repo_model.CreatePendingRepositoryTransfer(ctx, doer, newOwner, repo.ID, teams)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isDirectTransfer {
|
||||
notify_service.TransferRepository(ctx, doer, repo, oldOwnerName)
|
||||
} else {
|
||||
// notify users who are able to accept / reject transfer
|
||||
notify_service.RepoPendingTransfer(ctx, doer, newOwner, repo)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectRepositoryTransfer marks the repository as ready and remove pending transfer entry,
|
||||
// thus cancel the transfer process.
|
||||
// The accepter can reject the transfer.
|
||||
func RejectRepositoryTransfer(ctx context.Context, repo *repo_model.Repository, doer *user_model.User) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repoTransfer.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo_model.DeleteRepositoryTransfer(ctx, repo.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func canUserCancelTransfer(ctx context.Context, r *repo_model.RepoTransfer, u *user_model.User) bool {
|
||||
if u.IsAdmin || u.ID == r.DoerID {
|
||||
return true
|
||||
}
|
||||
|
||||
if err := r.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if err := r.Repo.LoadOwner(ctx); err != nil {
|
||||
log.Error("LoadOwner: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if !r.Repo.Owner.IsOrganization() {
|
||||
return r.Repo.OwnerID == u.ID
|
||||
}
|
||||
|
||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, r.Repo, u)
|
||||
if err != nil {
|
||||
log.Error("GetIndividualUserRepoPermission: %v", err)
|
||||
return false
|
||||
}
|
||||
return perm.IsOwner()
|
||||
}
|
||||
|
||||
// CancelRepositoryTransfer cancels the repository transfer process. The sender or
|
||||
// the users who have admin permission of the original repository can cancel the transfer
|
||||
func CancelRepositoryTransfer(ctx context.Context, repoTransfer *repo_model.RepoTransfer, doer *user_model.User) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := repoTransfer.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !canUserCancelTransfer(ctx, repoTransfer, doer) {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
repoTransfer.Repo.Status = repo_model.RepositoryReady
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repoTransfer.Repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo_model.DeleteRepositoryTransfer(ctx, repoTransfer.RepoID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/feed"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var notifySync sync.Once
|
||||
|
||||
func registerNotifier() {
|
||||
notifySync.Do(func() {
|
||||
notify_service.RegisterNotifier(feed.NewNotifier())
|
||||
})
|
||||
}
|
||||
|
||||
func TestTransferOwnership(t *testing.T) {
|
||||
registerNotifier()
|
||||
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
repoTransfer := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoTransfer{ID: 1})
|
||||
assert.NoError(t, repoTransfer.LoadAttributes(t.Context()))
|
||||
assert.NoError(t, AcceptTransferOwnership(t.Context(), repo, doer))
|
||||
|
||||
transferredRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.EqualValues(t, 1, transferredRepo.OwnerID) // repo_transfer.yml id=1
|
||||
unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{ID: 1})
|
||||
|
||||
exist, err := util.IsExist(repo_model.RepoPath("org3", "repo3"))
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exist)
|
||||
exist, err = util.IsExist(repo_model.RepoPath("user1", "repo3"))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
|
||||
OpType: activities_model.ActionTransferRepo,
|
||||
ActUserID: 1,
|
||||
RepoID: 3,
|
||||
Content: "org3/repo3",
|
||||
})
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{}, &user_model.User{}, &organization.Team{})
|
||||
}
|
||||
|
||||
func TestStartRepositoryTransferSetPermission(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
|
||||
hasAccess, err := access_model.HasAnyUnitAccess(t.Context(), recipient.ID, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
|
||||
assert.NoError(t, StartRepositoryTransfer(t.Context(), doer, recipient, repo, nil))
|
||||
|
||||
hasAccess, err = access_model.HasAnyUnitAccess(t.Context(), recipient.ID, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, hasAccess)
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{}, &user_model.User{}, &organization.Team{})
|
||||
}
|
||||
|
||||
func TestRepositoryTransfer(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
|
||||
transfer, err := repo_model.GetPendingRepositoryTransfer(t.Context(), repo)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, transfer)
|
||||
|
||||
// Cancel transfer
|
||||
assert.NoError(t, CancelRepositoryTransfer(t.Context(), transfer, doer))
|
||||
|
||||
transfer, err = repo_model.GetPendingRepositoryTransfer(t.Context(), repo)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, transfer)
|
||||
assert.True(t, repo_model.IsErrNoPendingTransfer(err))
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.NoError(t, repo_model.CreatePendingRepositoryTransfer(t.Context(), doer, user2, repo.ID, nil))
|
||||
|
||||
transfer, err = repo_model.GetPendingRepositoryTransfer(t.Context(), repo)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, transfer.LoadAttributes(t.Context()))
|
||||
assert.Equal(t, "user2", transfer.Recipient.Name)
|
||||
|
||||
org6 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// Only transfer can be started at any given time
|
||||
err = repo_model.CreatePendingRepositoryTransfer(t.Context(), doer, org6, repo.ID, nil)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, repo_model.IsErrRepoTransferInProgress(err))
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
// Unknown user, transfer non-existent transfer repo id = 2
|
||||
err = repo_model.CreatePendingRepositoryTransfer(t.Context(), doer, &user_model.User{ID: 1000, LowerName: "user1000"}, repo2.ID, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Reject transfer
|
||||
err = RejectRepositoryTransfer(t.Context(), repo2, doer)
|
||||
assert.True(t, repo_model.IsErrNoPendingTransfer(err))
|
||||
}
|
||||
|
||||
// Test transfer rejections
|
||||
func TestRepositoryTransferRejection(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// Set limit to 0 repositories so no repositories can be transferred
|
||||
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)()
|
||||
defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit, 0)()
|
||||
defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit, 0)()
|
||||
|
||||
// Admin case
|
||||
doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
|
||||
|
||||
transfer, err := repo_model.GetPendingRepositoryTransfer(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transfer)
|
||||
require.NoError(t, transfer.LoadRecipient(t.Context()))
|
||||
|
||||
require.True(t, doerAdmin.CanCreateRepoIn(transfer.Recipient)) // admin is not subject to limits
|
||||
|
||||
// Administrator should not be affected by the limits so transfer should be successful
|
||||
assert.NoError(t, AcceptTransferOwnership(t.Context(), repo, doerAdmin))
|
||||
|
||||
// Non admin user case
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21})
|
||||
|
||||
transfer, err = repo_model.GetPendingRepositoryTransfer(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transfer)
|
||||
require.NoError(t, transfer.LoadRecipient(t.Context()))
|
||||
|
||||
require.False(t, doer.CanCreateRepoIn(transfer.Recipient)) // regular user is subject to limits
|
||||
|
||||
// Cannot accept because of the limit
|
||||
err = AcceptTransferOwnership(t.Context(), repo, doer)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsRepositoryLimitReached(err))
|
||||
}
|
||||
Reference in New Issue
Block a user