初始提交: 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
+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
}