初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+376
View File
@@ -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
}
+125
View File
@@ -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
}
+367
View File
@@ -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")
})
}
+106
View File
@@ -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")
}
+70
View File
@@ -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)
}
+915
View File
@@ -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
}
+33
View File
@@ -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)
}
+200
View File
@@ -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
}
+130
View File
@@ -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)
}
+96
View File
@@ -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)
}
+55
View File
@@ -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
}
+297
View File
@@ -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"])
}
+476
View File
@@ -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
}
+55
View File
@@ -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)
}
+404
View File
@@ -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
}
+54
View File
@@ -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))
}
+158
View File
@@ -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
}
+34
View File
@@ -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
}
+308
View File
@@ -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
}
+48
View File
@@ -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)
})
}
+42
View File
@@ -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)
}
+155
View File
@@ -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))
})
}
+155
View File
@@ -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
}
+27
View File
@@ -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)
}
}
+222
View File
@@ -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
}
+407
View File
@@ -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())
})
}
+206
View File
@@ -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)
}
+123
View File
@@ -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)
}
+701
View File
@@ -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 youre 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 youre 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 youre 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
}
+111
View File
@@ -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
}
+256
View File
@@ -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,
})
}
+90
View File
@@ -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)
}
+321
View File
@@ -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, "/"), "/")))
}
+261
View File
@@ -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)
}
}
+104
View File
@@ -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
}
+695
View File
@@ -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 = `*
*
*
*
*
*
*
*
|\
* |
* |
* |
* |
* |
| *
* |
| *
| |\
* | |
| | *
| | |\
* | | \
|\ \ \ \
| * | | |
| |\| | |
* | | | |
|/ / / /
| | | *
| * | |
| * | |
| * | |
* | | |
* | | |
* | | |
* | | |
* | | |
|\ \ \ \
| | * | |
| | |\| |
| | | * |
| | | | *
* | | | |
* | | | |
* | | | |
* | | | |
* | | | |
|\ \ \ \ \
| * | | | |
|/| | | | |
| | |/ / /
| |/| | |
| | | | *
| * | | |
|/| | | |
| * | | |
|/| | | |
| | |/ /
| |/| |
| * | |
| * | |
| |\ \ \
| | * | |
| |/| | |
| | | |/
| | |/|
| * | |
| * | |
| * | |
| | * |
| | |\ \
| | | * |
| | |/| |
| | | * |
| | | |\ \
| | | | * |
| | | |/| |
| | * | | |
| | * | | |
| | |\ \ \ \
| | | * | | |
| | |/| | | |
| | | | | * |
| | | | |/ /
* | | | / /
|/ / / / /
* | | | |
|\ \ \ \ \
| * | | | |
|/| | | | |
| * | | | |
| * | | | |
| |\ \ \ \ \
| | | * \ \ \
| | | |\ \ \ \
| | | | * | | |
| | | |/| | | |
| | | | | |/ /
| | | | |/| |
* | | | | | |
* | | | | | |
* | | | | | |
| | | | * | |
* | | | | | |
| | * | | | |
| |/| | | | |
* | | | | | |
| |/ / / / /
|/| | | | |
| | | | * |
| | | |/ /
| | |/| |
| * | | |
| | | | *
| | * | |
| | |\ \ \
| | | * | |
| | |/| | |
| | | |/ /
| | | * |
| | * | |
| | |\ \ \
| | | * | |
| | |/| | |
| | | |/ /
| | | * |
* | | | |
|\ \ \ \ \
| * \ \ \ \
| |\ \ \ \ \
| | | |/ / /
| | |/| | |
| | | | * |
| | | | * |
* | | | | |
* | | | | |
|/ / / / /
| | | * |
* | | | |
* | | | |
* | | | |
* | | | |
|\ \ \ \ \
| * | | | |
|/| | | | |
| | * | | |
| | |\ \ \ \
| | | * | | |
| | |/| | | |
| |/| | |/ /
| | | |/| |
| | | | | *
| |_|_|_|/
|/| | | |
| | * | |
| |/ / /
* | | |
* | | |
| | * |
* | | |
* | | |
| * | |
| | * |
| * | |
* | | |
|\ \ \ \
| * | | |
|/| | | |
| |/ / /
| * | |
| |\ \ \
| | * | |
| |/| | |
| | |/ /
| | * |
| | |\ \
| | | * |
| | |/| |
* | | | |
* | | | |
|\ \ \ \ \
| * | | | |
|/| | | | |
| | * | | |
| | * | | |
| | * | | |
| |/ / / /
| * | | |
| |\ \ \ \
| | * | | |
| |/| | | |
* | | | | |
* | | | | |
* | | | | |
* | | | | |
* | | | | |
| | | | * |
* | | | | |
|\ \ \ \ \ \
| * | | | | |
|/| | | | | |
| | | | | * |
| | | | |/ /
* | | | | |
|\ \ \ \ \ \
* | | | | | |
* | | | | | |
| | | | * | |
* | | | | | |
* | | | | | |
|\ \ \ \ \ \ \
| | |_|_|/ / /
| |/| | | | |
| | | | * | |
| | | | * | |
| | | | * | |
| | | | * | |
| | | | * | |
| | | | * | |
| | | |/ / /
| | | * | |
| | | * | |
| | | * | |
| | |/| | |
| | | * | |
| | |/| | |
| | | |/ /
| | * | |
| |/| | |
| | | * |
| | |/ /
| | * |
| * | |
| |\ \ \
| * | | |
| | * | |
| |/| | |
| | |/ /
| | * |
| | |\ \
| | * | |
* | | | |
|\| | | |
| * | | |
| * | | |
| * | | |
| | * | |
| * | | |
| |\| | |
| * | | |
| | * | |
| | * | |
| * | | |
| * | | |
| * | | |
| * | | |
| * | | |
| * | | |
| * | | |
| * | | |
| | * | |
| * | | |
| * | | |
| * | | |
| * | | |
| | * | |
* | | | |
|\| | | |
| | * | |
| * | | |
| |\| | |
| | * | |
| | * | |
| | * | |
| | | * |
* | | | |
|\| | | |
| | * | |
| | |/ /
| * | |
| * | |
| |\| |
* | | |
|\| | |
| | * |
| | * |
| | * |
| * | |
| | * |
| * | |
| | * |
| | * |
| | * |
| * | |
| * | |
| * | |
| * | |
| * | |
| * | |
| * | |
* | | |
|\| | |
| * | |
| |\| |
| | * |
| | |\ \
* | | | |
|\| | | |
| * | | |
| |\| | |
| | * | |
| | | * |
| | |/ /
* | | |
* | | |
|\| | |
| * | |
| |\| |
| | * |
| | * |
| | * |
| | | *
* | | |
|\| | |
| * | |
| * | |
| | | *
| | | |\
* | | | |
| |_|_|/
|/| | |
| * | |
| |\| |
| | * |
| | * |
| | * |
| | * |
| | * |
| * | |
* | | |
|\| | |
| * | |
|/| | |
| |/ /
| * |
| |\ \
| * | |
| * | |
* | | |
|\| | |
| | * |
| * | |
| * | |
| * | |
* | | |
|\| | |
| * | |
| * | |
| | * |
| | |\ \
| | |/ /
| |/| |
| * | |
* | | |
|\| | |
| * | |
* | | |
|\| | |
| * | |
| |\ \ \
| * | | |
| * | | |
| | | * |
| * | | |
| * | | |
| | |/ /
| |/| |
| | * |
* | | |
|\| | |
| * | |
| * | |
| * | |
| * | |
| * | |
| |\ \ \
* | | | |
|\| | | |
| * | | |
| * | | |
* | | | |
* | | | |
|\| | | |
| | | | *
| | | | |\
| |_|_|_|/
|/| | | |
| * | | |
* | | | |
* | | | |
|\| | | |
| * | | |
| |\ \ \ \
| | | |/ /
| | |/| |
| * | | |
| * | | |
| * | | |
| * | | |
| | * | |
| | | * |
| | |/ /
| |/| |
* | | |
|\| | |
| * | |
| * | |
| * | |
| * | |
| * | |
* | | |
|\| | |
| * | |
| * | |
* | | |
| * | |
| * | |
| * | |
* | | |
* | | |
* | | |
|\| | |
| * | |
* | | |
* | | |
* | | |
* | | |
| | | *
* | | |
|\| | |
| * | |
| * | |
| * | |
`
+336
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
+81
View File
@@ -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
}
+137
View File
@@ -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
}
+91
View File
@@ -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
}
+168
View File
@@ -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
}
+70
View File
@@ -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])
})
}
+14
View File
@@ -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)
}
+128
View File
@@ -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
}
+286
View File
@@ -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)
}
+447
View File
@@ -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
}
+217
View File
@@ -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)
}
+33
View File
@@ -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})
}
+336
View File
@@ -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
}
+92
View File
@@ -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)
}
+52
View File
@@ -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
})
}
+194
View File
@@ -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
}
+566
View File
@@ -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)
})
}
+168
View File
@@ -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))
}