API endpoint for changing/creating/deleting multiple files (#24887)

This PR creates an API endpoint for creating/updating/deleting multiple
files in one API call similar to the solution provided by
[GitLab](https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions).

To archive this, the CreateOrUpdateRepoFile and DeleteRepoFIle functions
in files service are unified into one function supporting multiple files
and actions.

Resolves #14619
This commit is contained in:
Denys Konovalov 2023-05-29 11:41:35 +02:00 committed by GitHub
parent 245f2c08db
commit 275d4b7e3f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1309 additions and 778 deletions

View file

@ -64,6 +64,35 @@ func (o *UpdateFileOptions) Branch() string {
return o.FileOptions.BranchName return o.FileOptions.BranchName
} }
// ChangeFileOperation for creating, updating or deleting a file
type ChangeFileOperation struct {
// indicates what to do with the file
// required: true
// enum: create,update,delete
Operation string `json:"operation" binding:"Required"`
// path to the existing or new file
Path string `json:"path" binding:"MaxSize(500)"`
// content must be base64 encoded
// required: true
Content string `json:"content"`
// sha is the SHA for the file that already exists, required for update, delete
SHA string `json:"sha"`
// old path of the file to move
FromPath string `json:"from_path"`
}
// ChangeFilesOptions options for creating, updating or deleting multiple files
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
type ChangeFilesOptions struct {
FileOptions
Files []*ChangeFileOperation `json:"files"`
}
// Branch returns branch name
func (o *ChangeFilesOptions) Branch() string {
return o.FileOptions.BranchName
}
// FileOptionInterface provides a unified interface for the different file options // FileOptionInterface provides a unified interface for the different file options
type FileOptionInterface interface { type FileOptionInterface interface {
Branch() string Branch() string
@ -126,6 +155,13 @@ type FileResponse struct {
Verification *PayloadCommitVerification `json:"verification"` Verification *PayloadCommitVerification `json:"verification"`
} }
// FilesResponse contains information about multiple files from a repo
type FilesResponse struct {
Files []*ContentsResponse `json:"files"`
Commit *FileCommitResponse `json:"commit"`
Verification *PayloadCommitVerification `json:"verification"`
}
// FileDeleteResponse contains information about a repo's file that was deleted // FileDeleteResponse contains information about a repo's file that was deleted
type FileDeleteResponse struct { type FileDeleteResponse struct {
Content interface{} `json:"content"` // to be set to nil Content interface{} `json:"content"` // to be set to nil

View file

@ -1173,6 +1173,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(auth_model.AccessTokenScopeRepo), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch) m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(auth_model.AccessTokenScopeRepo), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch)
m.Group("/contents", func() { m.Group("/contents", func() {
m.Get("", repo.GetContentsList) m.Get("", repo.GetContentsList)
m.Post("", reqToken(auth_model.AccessTokenScopeRepo), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles)
m.Get("/*", repo.GetContents) m.Get("/*", repo.GetContents)
m.Group("/*", func() { m.Group("/*", func() {
m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile) m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile)

View file

@ -12,6 +12,7 @@ import (
"io" "io"
"net/http" "net/http"
"path" "path"
"strings"
"time" "time"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
@ -407,6 +408,96 @@ func canReadFiles(r *context.Repository) bool {
return r.Permission.CanRead(unit.TypeCode) return r.Permission.CanRead(unit.TypeCode)
} }
// ChangeFiles handles API call for creating or updating multiple files
func ChangeFiles(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles
// ---
// summary: Create or update multiple files in a repository
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/ChangeFilesOptions"
// responses:
// "201":
// "$ref": "#/responses/FilesResponse"
// "403":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/error"
apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions)
if apiOpts.BranchName == "" {
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
}
files := []*files_service.ChangeRepoFile{}
for _, file := range apiOpts.Files {
changeRepoFile := &files_service.ChangeRepoFile{
Operation: file.Operation,
TreePath: file.Path,
FromTreePath: file.FromPath,
Content: file.Content,
SHA: file.SHA,
}
files = append(files, changeRepoFile)
}
opts := &files_service.ChangeRepoFilesOptions{
Files: files,
Message: apiOpts.Message,
OldBranch: apiOpts.BranchName,
NewBranch: apiOpts.NewBranchName,
Committer: &files_service.IdentityOptions{
Name: apiOpts.Committer.Name,
Email: apiOpts.Committer.Email,
},
Author: &files_service.IdentityOptions{
Name: apiOpts.Author.Name,
Email: apiOpts.Author.Email,
},
Dates: &files_service.CommitDateOptions{
Author: apiOpts.Dates.Author,
Committer: apiOpts.Dates.Committer,
},
Signoff: apiOpts.Signoff,
}
if opts.Dates.Author.IsZero() {
opts.Dates.Author = time.Now()
}
if opts.Dates.Committer.IsZero() {
opts.Dates.Committer = time.Now()
}
if opts.Message == "" {
opts.Message = changeFilesCommitMessage(ctx, files)
}
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
handleCreateOrUpdateFileError(ctx, err)
} else {
ctx.JSON(http.StatusCreated, filesResponse)
}
}
// CreateFile handles API call for creating a file // CreateFile handles API call for creating a file
func CreateFile(ctx *context.APIContext) { func CreateFile(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile // swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile
@ -453,11 +544,15 @@ func CreateFile(ctx *context.APIContext) {
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
} }
opts := &files_service.UpdateRepoFileOptions{ opts := &files_service.ChangeRepoFilesOptions{
Content: apiOpts.Content, Files: []*files_service.ChangeRepoFile{
IsNewFile: true, {
Message: apiOpts.Message, Operation: "create",
TreePath: ctx.Params("*"), TreePath: ctx.Params("*"),
Content: apiOpts.Content,
},
},
Message: apiOpts.Message,
OldBranch: apiOpts.BranchName, OldBranch: apiOpts.BranchName,
NewBranch: apiOpts.NewBranchName, NewBranch: apiOpts.NewBranchName,
Committer: &files_service.IdentityOptions{ Committer: &files_service.IdentityOptions{
@ -482,12 +577,13 @@ func CreateFile(ctx *context.APIContext) {
} }
if opts.Message == "" { if opts.Message == "" {
opts.Message = ctx.Tr("repo.editor.add", opts.TreePath) opts.Message = changeFilesCommitMessage(ctx, opts.Files)
} }
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil { if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
handleCreateOrUpdateFileError(ctx, err) handleCreateOrUpdateFileError(ctx, err)
} else { } else {
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
ctx.JSON(http.StatusCreated, fileResponse) ctx.JSON(http.StatusCreated, fileResponse)
} }
} }
@ -540,13 +636,17 @@ func UpdateFile(ctx *context.APIContext) {
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
} }
opts := &files_service.UpdateRepoFileOptions{ opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "update",
Content: apiOpts.Content, Content: apiOpts.Content,
SHA: apiOpts.SHA, SHA: apiOpts.SHA,
IsNewFile: false,
Message: apiOpts.Message,
FromTreePath: apiOpts.FromPath, FromTreePath: apiOpts.FromPath,
TreePath: ctx.Params("*"), TreePath: ctx.Params("*"),
},
},
Message: apiOpts.Message,
OldBranch: apiOpts.BranchName, OldBranch: apiOpts.BranchName,
NewBranch: apiOpts.NewBranchName, NewBranch: apiOpts.NewBranchName,
Committer: &files_service.IdentityOptions{ Committer: &files_service.IdentityOptions{
@ -571,12 +671,13 @@ func UpdateFile(ctx *context.APIContext) {
} }
if opts.Message == "" { if opts.Message == "" {
opts.Message = ctx.Tr("repo.editor.update", opts.TreePath) opts.Message = changeFilesCommitMessage(ctx, opts.Files)
} }
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil { if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
handleCreateOrUpdateFileError(ctx, err) handleCreateOrUpdateFileError(ctx, err)
} else { } else {
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
ctx.JSON(http.StatusOK, fileResponse) ctx.JSON(http.StatusOK, fileResponse)
} }
} }
@ -600,7 +701,7 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
} }
// Called from both CreateFile or UpdateFile to handle both // Called from both CreateFile or UpdateFile to handle both
func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoFileOptions) (*api.FileResponse, error) { func createOrUpdateFiles(ctx *context.APIContext, opts *files_service.ChangeRepoFilesOptions) (*api.FilesResponse, error) {
if !canWriteFiles(ctx, opts.OldBranch) { if !canWriteFiles(ctx, opts.OldBranch) {
return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{ return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{
UserID: ctx.Doer.ID, UserID: ctx.Doer.ID,
@ -608,13 +709,45 @@ func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoF
} }
} }
content, err := base64.StdEncoding.DecodeString(opts.Content) for _, file := range opts.Files {
content, err := base64.StdEncoding.DecodeString(file.Content)
if err != nil { if err != nil {
return nil, err return nil, err
} }
opts.Content = string(content) file.Content = string(content)
}
return files_service.CreateOrUpdateRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts) return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts)
}
// format commit message if empty
func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string {
var (
createFiles []string
updateFiles []string
deleteFiles []string
)
for _, file := range files {
switch file.Operation {
case "create":
createFiles = append(createFiles, file.TreePath)
case "update":
updateFiles = append(updateFiles, file.TreePath)
case "delete":
deleteFiles = append(deleteFiles, file.TreePath)
}
}
message := ""
if len(createFiles) != 0 {
message += ctx.Tr("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
}
if len(updateFiles) != 0 {
message += ctx.Tr("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
}
if len(deleteFiles) != 0 {
message += ctx.Tr("repo.editor.delete", strings.Join(deleteFiles, ", "))
}
return strings.Trim(message, "\n")
} }
// DeleteFile Delete a file in a repository // DeleteFile Delete a file in a repository
@ -670,12 +803,17 @@ func DeleteFile(ctx *context.APIContext) {
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
} }
opts := &files_service.DeleteRepoFileOptions{ opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
SHA: apiOpts.SHA,
TreePath: ctx.Params("*"),
},
},
Message: apiOpts.Message, Message: apiOpts.Message,
OldBranch: apiOpts.BranchName, OldBranch: apiOpts.BranchName,
NewBranch: apiOpts.NewBranchName, NewBranch: apiOpts.NewBranchName,
SHA: apiOpts.SHA,
TreePath: ctx.Params("*"),
Committer: &files_service.IdentityOptions{ Committer: &files_service.IdentityOptions{
Name: apiOpts.Committer.Name, Name: apiOpts.Committer.Name,
Email: apiOpts.Committer.Email, Email: apiOpts.Committer.Email,
@ -698,10 +836,10 @@ func DeleteFile(ctx *context.APIContext) {
} }
if opts.Message == "" { if opts.Message == "" {
opts.Message = ctx.Tr("repo.editor.delete", opts.TreePath) opts.Message = changeFilesCommitMessage(ctx, opts.Files)
} }
if fileResponse, err := files_service.DeleteRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
if git.IsErrBranchNotExist(err) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) { if git.IsErrBranchNotExist(err) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
ctx.Error(http.StatusNotFound, "DeleteFile", err) ctx.Error(http.StatusNotFound, "DeleteFile", err)
return return
@ -718,6 +856,7 @@ func DeleteFile(ctx *context.APIContext) {
} }
ctx.Error(http.StatusInternalServerError, "DeleteFile", err) ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
} else { } else {
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
} }
} }

View file

@ -116,6 +116,9 @@ type swaggerParameterBodies struct {
// in:body // in:body
EditAttachmentOptions api.EditAttachmentOptions EditAttachmentOptions api.EditAttachmentOptions
// in:body
ChangeFilesOptions api.ChangeFilesOptions
// in:body // in:body
CreateFileOptions api.CreateFileOptions CreateFileOptions api.CreateFileOptions

View file

@ -296,6 +296,13 @@ type swaggerFileResponse struct {
Body api.FileResponse `json:"body"` Body api.FileResponse `json:"body"`
} }
// FilesResponse
// swagger:response FilesResponse
type swaggerFilesResponse struct {
// in: body
Body api.FilesResponse `json:"body"`
}
// ContentsResponse // ContentsResponse
// swagger:response ContentsResponse // swagger:response ContentsResponse
type swaggerContentsResponse struct { type swaggerContentsResponse struct {

View file

@ -272,18 +272,27 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
message += "\n\n" + form.CommitMessage message += "\n\n" + form.CommitMessage
} }
if _, err := files_service.CreateOrUpdateRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UpdateRepoFileOptions{ operation := "update"
if isNewFile {
operation = "create"
}
if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: form.LastCommit, LastCommitID: form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: ctx.Repo.BranchName,
NewBranch: branchName, NewBranch: branchName,
Message: message,
Files: []*files_service.ChangeRepoFile{
{
Operation: operation,
FromTreePath: ctx.Repo.TreePath, FromTreePath: ctx.Repo.TreePath,
TreePath: form.TreePath, TreePath: form.TreePath,
Message: message,
Content: strings.ReplaceAll(form.Content, "\r", ""), Content: strings.ReplaceAll(form.Content, "\r", ""),
IsNewFile: isNewFile, },
},
Signoff: form.Signoff, Signoff: form.Signoff,
}); err != nil { }); err != nil {
// This is where we handle all the errors thrown by files_service.CreateOrUpdateRepoFile // This is where we handle all the errors thrown by files_service.ChangeRepoFiles
if git.IsErrNotExist(err) { if git.IsErrNotExist(err) {
ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form)
} else if git_model.IsErrLFSFileLocked(err) { } else if git_model.IsErrLFSFileLocked(err) {
@ -478,11 +487,16 @@ func DeleteFilePost(ctx *context.Context) {
message += "\n\n" + form.CommitMessage message += "\n\n" + form.CommitMessage
} }
if _, err := files_service.DeleteRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.DeleteRepoFileOptions{ if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: form.LastCommit, LastCommitID: form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: ctx.Repo.BranchName,
NewBranch: branchName, NewBranch: branchName,
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: ctx.Repo.TreePath, TreePath: ctx.Repo.TreePath,
},
},
Message: message, Message: message,
Signoff: form.Signoff, Signoff: form.Signoff,
}); err != nil { }); err != nil {

View file

@ -1,204 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package files
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs"
)
// DeleteRepoFileOptions holds the repository delete file options
type DeleteRepoFileOptions struct {
LastCommitID string
OldBranch string
NewBranch string
TreePath string
Message string
SHA string
Author *IdentityOptions
Committer *IdentityOptions
Dates *CommitDateOptions
Signoff bool
}
// DeleteRepoFile deletes a file in the given repository
func DeleteRepoFile(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *DeleteRepoFileOptions) (*api.FileResponse, error) {
// If no branch name is set, assume the repo's default branch
if opts.OldBranch == "" {
opts.OldBranch = repo.DefaultBranch
}
if opts.NewBranch == "" {
opts.NewBranch = opts.OldBranch
}
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath())
if err != nil {
return nil, err
}
defer closer.Close()
// oldBranch must exist for this operation
if _, err := gitRepo.GetBranch(opts.OldBranch); err != nil {
return nil, err
}
// 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 {
newBranch, err := gitRepo.GetBranch(opts.NewBranch)
if err != nil && !git.IsErrBranchNotExist(err) {
return nil, err
}
if newBranch != nil {
return nil, models.ErrBranchAlreadyExists{
BranchName: opts.NewBranch,
}
}
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, opts.TreePath); err != nil {
return nil, err
}
// Check that the path given in opts.treeName is valid (not a git path)
treePath := CleanUploadFileName(opts.TreePath)
if treePath == "" {
return nil, models.ErrFilenameInvalid{
Path: opts.TreePath,
}
}
message := strings.TrimSpace(opts.Message)
author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer)
t, err := NewTemporaryUploadRepository(ctx, repo)
if err != nil {
return nil, err
}
defer t.Close()
if err := t.Clone(opts.OldBranch); err != nil {
return nil, err
}
if err := t.SetDefaultIndex(); 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.ConvertToSHA1(opts.LastCommitID)
if err != nil {
return nil, fmt.Errorf("DeleteRepoFile: Invalid last commit ID: %w", err)
}
opts.LastCommitID = lastCommitID.String()
}
// Get the files in the index
filesInIndex, err := t.LsFiles(opts.TreePath)
if err != nil {
return nil, fmt.Errorf("DeleteRepoFile: %w", err)
}
// Find the file we want to delete in the index
inFilelist := false
for _, file := range filesInIndex {
if file == opts.TreePath {
inFilelist = true
break
}
}
if !inFilelist {
return nil, models.ErrRepoFileDoesNotExist{
Path: opts.TreePath,
}
}
// Get the entry of treePath and check if the SHA given is the same as the file
entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil {
return nil, err
}
if opts.SHA != "" {
// If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error
if opts.SHA != entry.ID.String() {
return nil, models.ErrSHADoesNotMatch{
Path: treePath,
GivenSHA: opts.SHA,
CurrentSHA: entry.ID.String(),
}
}
} else if opts.LastCommitID != "" {
// If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw
// an error, but only if we aren't creating a new branch.
if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch {
// CommitIDs don't match, but we don't want to throw a ErrCommitIDDoesNotMatch unless
// this specific file has been edited since opts.LastCommitID
if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil {
return nil, err
} else if changed {
return nil, models.ErrCommitIDDoesNotMatch{
GivenCommitID: opts.LastCommitID,
CurrentCommitID: opts.LastCommitID,
}
}
// The file wasn't modified, so we are good to delete it
}
} else {
// When deleting 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 nil, models.ErrSHAOrCommitIDNotProvided{}
}
// Remove the file from the index
if err := t.RemoveFilesFromIndex(opts.TreePath); err != nil {
return nil, err
}
// Now write the tree
treeHash, err := t.WriteTree()
if err != nil {
return nil, err
}
// Now commit the tree
var commitHash string
if opts.Dates != nil {
commitHash, err = t.CommitTreeWithDate("HEAD", author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer)
} else {
commitHash, err = t.CommitTree("HEAD", author, committer, treeHash, message, opts.Signoff)
}
if err != nil {
return nil, err
}
// Then push this tree to NewBranch
if err := t.Push(doer, commitHash, opts.NewBranch); err != nil {
return nil, err
}
commit, err = t.GetCommit(commitHash)
if err != nil {
return nil, err
}
file, err := GetFileResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePath)
if err != nil {
return nil, err
}
return file, nil
}

View file

@ -17,6 +17,22 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch string, treeNames []string) (*api.FilesResponse, error) {
files := []*api.ContentsResponse{}
for _, file := range treeNames {
fileContents, _ := GetContents(ctx, repo, file, branch, false) // ok if fails, then will be nil
files = append(files, fileContents)
}
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
verification := GetPayloadCommitVerification(ctx, commit)
filesResponse := &api.FilesResponse{
Files: files,
Commit: fileCommitResponse,
Verification: verification,
}
return filesResponse, nil
}
// GetFileResponseFromCommit Constructs a FileResponse from a Commit object // GetFileResponseFromCommit Constructs a FileResponse from a Commit object
func GetFileResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch, treeName string) (*api.FileResponse, error) { func GetFileResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch, treeName string) (*api.FileResponse, error) {
fileContents, _ := GetContents(ctx, repo, treeName, branch, false) // ok if fails, then will be nil fileContents, _ := GetContents(ctx, repo, treeName, branch, false) // ok if fails, then will be nil
@ -30,6 +46,20 @@ func GetFileResponseFromCommit(ctx context.Context, repo *repo_model.Repository,
return fileResponse, nil return fileResponse, 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 // GetFileCommitResponse Constructs a FileCommitResponse from a Commit object
func GetFileCommitResponse(repo *repo_model.Repository, commit *git.Commit) (*api.FileCommitResponse, error) { func GetFileCommitResponse(repo *repo_model.Repository, commit *git.Commit) (*api.FileCommitResponse, error) {
if repo == nil { if repo == nil {

View file

@ -41,23 +41,36 @@ type CommitDateOptions struct {
Committer time.Time Committer time.Time
} }
// UpdateRepoFileOptions holds the repository file update options type ChangeRepoFile struct {
type UpdateRepoFileOptions struct { Operation string
TreePath string
FromTreePath string
Content string
SHA string
Options *RepoFileOptions
}
// UpdateRepoFilesOptions holds the repository files update options
type ChangeRepoFilesOptions struct {
LastCommitID string LastCommitID string
OldBranch string OldBranch string
NewBranch string NewBranch string
TreePath string
FromTreePath string
Message string Message string
Content string Files []*ChangeRepoFile
SHA string
IsNewFile bool
Author *IdentityOptions Author *IdentityOptions
Committer *IdentityOptions Committer *IdentityOptions
Dates *CommitDateOptions Dates *CommitDateOptions
Signoff bool Signoff bool
} }
type RepoFileOptions struct {
treePath string
fromTreePath string
encoding string
bom bool
executable bool
}
func detectEncodingAndBOM(entry *git.TreeEntry, repo *repo_model.Repository) (string, bool) { func detectEncodingAndBOM(entry *git.TreeEntry, repo *repo_model.Repository) (string, bool) {
reader, err := entry.Blob().DataAsync() reader, err := entry.Blob().DataAsync()
if err != nil { if err != nil {
@ -125,8 +138,8 @@ func detectEncodingAndBOM(entry *git.TreeEntry, repo *repo_model.Repository) (st
return encoding, false return encoding, false
} }
// CreateOrUpdateRepoFile adds or updates a file in the given repository // ChangeRepoFiles adds, updates or removes multiple files in the given repository
func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *UpdateRepoFileOptions) (*structs.FileResponse, error) { func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (*structs.FilesResponse, error) {
// If no branch name is set, assume default branch // If no branch name is set, assume default branch
if opts.OldBranch == "" { if opts.OldBranch == "" {
opts.OldBranch = repo.DefaultBranch opts.OldBranch = repo.DefaultBranch
@ -146,6 +159,38 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
return nil, err return nil, err
} }
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 := CleanUploadFileName(file.TreePath)
if treePath == "" {
return nil, models.ErrFilenameInvalid{
Path: file.TreePath,
}
}
// If there is a fromTreePath (we are copying it), also clean it up
fromTreePath := CleanUploadFileName(file.FromTreePath)
if fromTreePath == "" && file.FromTreePath != "" {
return nil, models.ErrFilenameInvalid{
Path: file.FromTreePath,
}
}
file.Options = &RepoFileOptions{
treePath: treePath,
fromTreePath: fromTreePath,
encoding: "UTF-8",
bom: false,
executable: false,
}
treePaths = append(treePaths, treePath)
}
// A NewBranch can be specified for the file to be created/updated in a new branch. // 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. // 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 we aren't branching to a new branch, make sure user can commit to the given branch
@ -159,30 +204,10 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
if err != nil && !git.IsErrBranchNotExist(err) { if err != nil && !git.IsErrBranchNotExist(err) {
return nil, err return nil, err
} }
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, opts.TreePath); err != nil { } else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths); err != nil {
return nil, err return nil, err
} }
// If FromTreePath is not set, set it to the opts.TreePath
if opts.TreePath != "" && opts.FromTreePath == "" {
opts.FromTreePath = opts.TreePath
}
// Check that the path given in opts.treePath is valid (not a git path)
treePath := CleanUploadFileName(opts.TreePath)
if treePath == "" {
return nil, models.ErrFilenameInvalid{
Path: opts.TreePath,
}
}
// If there is a fromTreePath (we are copying it), also clean it up
fromTreePath := CleanUploadFileName(opts.FromTreePath)
if fromTreePath == "" && opts.FromTreePath != "" {
return nil, models.ErrFilenameInvalid{
Path: opts.FromTreePath,
}
}
message := strings.TrimSpace(opts.Message) message := strings.TrimSpace(opts.Message)
author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer) author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer)
@ -194,6 +219,11 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
defer t.Close() defer t.Close()
hasOldBranch := true hasOldBranch := true
if err := t.Clone(opts.OldBranch); err != nil { if err := t.Clone(opts.OldBranch); err != nil {
for _, file := range opts.Files {
if file.Operation == "delete" {
return nil, err
}
}
if !git.IsErrBranchNotExist(err) || !repo.IsEmpty { if !git.IsErrBranchNotExist(err) || !repo.IsEmpty {
return nil, err return nil, err
} }
@ -209,9 +239,29 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
} }
} }
encoding := "UTF-8" for _, file := range opts.Files {
bom := false if file.Operation == "delete" {
executable := false // Get the files in the index
filesInIndex, err := t.LsFiles(file.TreePath)
if err != nil {
return nil, fmt.Errorf("DeleteRepoFile: %w", err)
}
// Find the file we want to delete in the index
inFilelist := false
for _, indexFile := range filesInIndex {
if indexFile == file.TreePath {
inFilelist = true
break
}
}
if !inFilelist {
return nil, models.ErrRepoFileDoesNotExist{
Path: file.TreePath,
}
}
}
}
if hasOldBranch { if hasOldBranch {
// Get the commit of the original branch // Get the commit of the original branch
@ -232,176 +282,27 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
} }
if !opts.IsNewFile { for _, file := range opts.Files {
fromEntry, err := commit.GetTreeEntryByPath(fromTreePath) if err := handleCheckErrors(file, commit, opts, repo); err != nil {
if err != nil {
return nil, err return nil, err
} }
if opts.SHA != "" {
// If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error
if opts.SHA != fromEntry.ID.String() {
return nil, models.ErrSHADoesNotMatch{
Path: treePath,
GivenSHA: opts.SHA,
CurrentSHA: fromEntry.ID.String(),
}
}
} else if opts.LastCommitID != "" {
// If a lastCommitID was given and it doesn't match the commitID of the head of the branch 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(treePath, opts.LastCommitID); err != nil {
return nil, err
} else if changed {
return nil, models.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 nil, models.ErrSHAOrCommitIDNotProvided{}
}
encoding, bom = detectEncodingAndBOM(fromEntry, repo)
executable = fromEntry.IsExecutable()
}
// For the path where this file will be created/updated, 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(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 nil, err
}
if index < len(treePathParts)-1 {
if !entry.IsDir() {
return nil, models.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 nil, models.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 nil, models.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 fromTreePath != treePath || opts.IsNewFile {
// The entry shouldn't exist if we are creating new file or moving to a new path
return nil, models.ErrRepoFileAlreadyExists{
Path: treePath,
}
}
}
}
// Get the two paths (might be the same if not moving) from the index if they exist
filesInIndex, err := t.LsFiles(opts.TreePath, opts.FromTreePath)
if err != nil {
return nil, fmt.Errorf("UpdateRepoFile: %w", err)
}
// If is a new file (not updating) then the given path shouldn't exist
if opts.IsNewFile {
for _, file := range filesInIndex {
if file == opts.TreePath {
return nil, models.ErrRepoFileAlreadyExists{
Path: opts.TreePath,
}
}
} }
} }
// Remove the old path from the tree contentStore := lfs.NewContentStore()
if fromTreePath != treePath && len(filesInIndex) > 0 { for _, file := range opts.Files {
for _, file := range filesInIndex { switch file.Operation {
if file == fromTreePath { case "create", "update":
if err := t.RemoveFilesFromIndex(opts.FromTreePath); err != nil { if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil {
return nil, err return nil, err
} }
} case "delete":
} // Remove the file from the index
} if err := t.RemoveFilesFromIndex(file.TreePath); err != nil {
content := opts.Content
if bom {
content = string(charset.UTF8BOM) + content
}
if encoding != "UTF-8" {
charsetEncoding, _ := stdcharset.Lookup(encoding)
if charsetEncoding != nil {
result, _, err := transform.String(charsetEncoding.NewEncoder(), content)
if err != nil {
// Look if we can't encode back in to the original we should just stick with utf-8
log.Error("Error re-encoding %s (%s) as %s - will stay as UTF-8: %v", opts.TreePath, opts.FromTreePath, encoding, err)
result = content
}
content = result
} else {
log.Error("Unknown encoding: %s", encoding)
}
}
// Reset the opts.Content to our adjusted content to ensure that LFS gets the correct content
opts.Content = content
var lfsMetaObject *git_model.LFSMetaObject
if setting.LFS.StartServer && hasOldBranch {
// Check there is no way this can return multiple infos
filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{
Attributes: []string{"filter"},
Filenames: []string{treePath},
CachedOnly: true,
})
if err != nil {
return nil, err return nil, err
} }
default:
if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" { return nil, fmt.Errorf("Invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
// OK so we are supposed to LFS this data!
pointer, err := lfs.GeneratePointer(strings.NewReader(opts.Content))
if err != nil {
return nil, err
}
lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repo.ID}
content = pointer.StringContent()
}
}
// Add the object to the database
objectHash, err := t.HashObject(strings.NewReader(content))
if err != nil {
return nil, err
}
// Add the object to the index
if executable {
if err := t.AddObjectToIndex("100755", objectHash, treePath); err != nil {
return nil, err
}
} else {
if err := t.AddObjectToIndex("100644", objectHash, treePath); err != nil {
return nil, err
} }
} }
@ -422,27 +323,6 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
return nil, err return nil, err
} }
if lfsMetaObject != nil {
// We have an LFS object - create it
lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject)
if err != nil {
return nil, err
}
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(lfsMetaObject.Pointer)
if err != nil {
return nil, err
}
if !exist {
if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(opts.Content)); err != nil {
if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repo.ID, lfsMetaObject.Oid); err2 != nil {
return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err)
}
return nil, err
}
}
}
// Then push this tree to NewBranch // Then push this tree to NewBranch
if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { if err := t.Push(doer, commitHash, opts.NewBranch); err != nil {
log.Error("%T %v", err, err) log.Error("%T %v", err, err)
@ -454,7 +334,7 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
return nil, err return nil, err
} }
file, err := GetFileResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePath) filesReponse, err := GetFilesResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePaths)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -463,27 +343,240 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
_ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false}, "is_empty") _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false}, "is_empty")
} }
return file, nil return filesReponse, nil
}
// handles the check for various issues for ChangeRepoFiles
func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions, repo *repo_model.Repository) error {
if file.Operation == "update" || file.Operation == "delete" {
fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
if err != nil {
return err
}
if file.SHA != "" {
// If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error
if file.SHA != fromEntry.ID.String() {
return models.ErrSHADoesNotMatch{
Path: file.Options.treePath,
GivenSHA: file.SHA,
CurrentSHA: fromEntry.ID.String(),
}
}
} else if opts.LastCommitID != "" {
// If a lastCommitID was given and it doesn't match the commitID of the head of the branch 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 models.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 models.ErrSHAOrCommitIDNotProvided{}
}
file.Options.encoding, file.Options.bom = detectEncodingAndBOM(fromEntry, repo)
file.Options.executable = fromEntry.IsExecutable()
}
if file.Operation == "create" || file.Operation == "update" {
// For the path where this file will be created/updated, 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 models.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 models.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 models.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 new file or moving to a new path
return models.ErrRepoFileAlreadyExists{
Path: file.Options.treePath,
}
}
}
}
return nil
}
// handle creating or updating a file for ChangeRepoFiles
func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64, hasOldBranch bool) error {
// Get the two paths (might be the same if not moving) from the index if they exist
filesInIndex, err := t.LsFiles(file.TreePath, file.FromTreePath)
if err != nil {
return fmt.Errorf("UpdateRepoFile: %w", err)
}
// If is a new file (not updating) then the given path shouldn't exist
if file.Operation == "create" {
for _, indexFile := range filesInIndex {
if indexFile == file.TreePath {
return models.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(file.FromTreePath); err != nil {
return err
}
}
}
}
content := file.Content
if file.Options.bom {
content = string(charset.UTF8BOM) + content
}
if file.Options.encoding != "UTF-8" {
charsetEncoding, _ := stdcharset.Lookup(file.Options.encoding)
if charsetEncoding != nil {
result, _, err := transform.String(charsetEncoding.NewEncoder(), content)
if err != nil {
// Look if we can't encode back in to the original we should just stick with utf-8
log.Error("Error re-encoding %s (%s) as %s - will stay as UTF-8: %v", file.TreePath, file.FromTreePath, file.Options.encoding, err)
result = content
}
content = result
} else {
log.Error("Unknown encoding: %s", file.Options.encoding)
}
}
// Reset the opts.Content to our adjusted content to ensure that LFS gets the correct content
file.Content = content
var lfsMetaObject *git_model.LFSMetaObject
if setting.LFS.StartServer && hasOldBranch {
// Check there is no way this can return multiple infos
filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{
Attributes: []string{"filter"},
Filenames: []string{file.Options.treePath},
CachedOnly: true,
})
if err != nil {
return err
}
if filename2attribute2info[file.Options.treePath] != nil && filename2attribute2info[file.Options.treePath]["filter"] == "lfs" {
// OK so we are supposed to LFS this data!
pointer, err := lfs.GeneratePointer(strings.NewReader(file.Content))
if err != nil {
return err
}
lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID}
content = pointer.StringContent()
}
}
// Add the object to the database
objectHash, err := t.HashObject(strings.NewReader(content))
if err != nil {
return err
}
// Add the object to the index
if file.Options.executable {
if err := t.AddObjectToIndex("100755", objectHash, file.Options.treePath); err != nil {
return err
}
} else {
if err := t.AddObjectToIndex("100644", objectHash, file.Options.treePath); err != nil {
return err
}
}
if lfsMetaObject != nil {
// We have an LFS object - create it
lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject)
if err != nil {
return err
}
exist, err := contentStore.Exists(lfsMetaObject.Pointer)
if err != nil {
return err
}
if !exist {
if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(file.Content)); err != nil {
if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil {
return fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err)
}
return err
}
}
}
return nil
} }
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch // VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName, treePath string) error { func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
if err != nil { if err != nil {
return err return err
} }
if protectedBranch != nil { if protectedBranch != nil {
protectedBranch.Repo = repo protectedBranch.Repo = repo
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
globProtected := protectedBranch.GetProtectedFilePatterns()
canUserPush := protectedBranch.CanUserPush(ctx, doer)
for _, treePath := range treePaths {
isUnprotectedFile := false isUnprotectedFile := false
glob := protectedBranch.GetUnprotectedFilePatterns() if len(globUnprotected) != 0 {
if len(glob) != 0 { isUnprotectedFile = protectedBranch.IsUnprotectedFile(globUnprotected, treePath)
isUnprotectedFile = protectedBranch.IsUnprotectedFile(glob, treePath)
} }
if !protectedBranch.CanUserPush(ctx, doer) && !isUnprotectedFile { if !canUserPush && !isUnprotectedFile {
return models.ErrUserCannotCommit{ return models.ErrUserCannotCommit{
UserName: doer.LowerName, UserName: doer.LowerName,
} }
} }
if protectedBranch.IsProtectedFile(globProtected, treePath) {
return models.ErrFilePathProtected{
Path: treePath,
}
}
}
if protectedBranch.RequireSignedCommits { if protectedBranch.RequireSignedCommits {
_, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), branchName) _, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), branchName)
if err != nil { if err != nil {
@ -495,14 +588,6 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do
} }
} }
} }
patterns := protectedBranch.GetProtectedFilePatterns()
for _, pat := range patterns {
if pat.Match(strings.ToLower(treePath)) {
return models.ErrFilePathProtected{
Path: treePath,
}
}
}
} }
return nil return nil
} }

View file

@ -4063,6 +4063,57 @@
"$ref": "#/responses/notFound" "$ref": "#/responses/notFound"
} }
} }
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Create or update multiple files in a repository",
"operationId": "repoChangeFiles",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChangeFilesOptions"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/FilesResponse"
},
"403": {
"$ref": "#/responses/error"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/error"
}
}
} }
}, },
"/repos/{owner}/{repo}/contents/{filepath}": { "/repos/{owner}/{repo}/contents/{filepath}": {
@ -15891,6 +15942,90 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"ChangeFileOperation": {
"description": "ChangeFileOperation for creating, updating or deleting a file",
"type": "object",
"required": [
"operation",
"content"
],
"properties": {
"content": {
"description": "content must be base64 encoded",
"type": "string",
"x-go-name": "Content"
},
"from_path": {
"description": "old path of the file to move",
"type": "string",
"x-go-name": "FromPath"
},
"operation": {
"description": "indicates what to do with the file",
"type": "string",
"enum": [
"create",
"update",
"delete"
],
"x-go-name": "Operation"
},
"path": {
"description": "path to the existing or new file",
"type": "string",
"x-go-name": "Path"
},
"sha": {
"description": "sha is the SHA for the file that already exists, required for update, delete",
"type": "string",
"x-go-name": "SHA"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"ChangeFilesOptions": {
"description": "ChangeFilesOptions options for creating, updating or deleting multiple files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)",
"type": "object",
"properties": {
"author": {
"$ref": "#/definitions/Identity"
},
"branch": {
"description": "branch (optional) to base this file from. if not given, the default branch is used",
"type": "string",
"x-go-name": "BranchName"
},
"committer": {
"$ref": "#/definitions/Identity"
},
"dates": {
"$ref": "#/definitions/CommitDateOptions"
},
"files": {
"type": "array",
"items": {
"$ref": "#/definitions/ChangeFileOperation"
},
"x-go-name": "Files"
},
"message": {
"description": "message (optional) for the commit of this file. if not supplied, a default message will be used",
"type": "string",
"x-go-name": "Message"
},
"new_branch": {
"description": "new_branch (optional) will make a new branch from `branch` before creating the file",
"type": "string",
"x-go-name": "NewBranchName"
},
"signoff": {
"description": "Add a Signed-off-by trailer by the committer at the end of the commit log message.",
"type": "boolean",
"x-go-name": "Signoff"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"ChangedFile": { "ChangedFile": {
"description": "ChangedFile store information about files affected by the pull request", "description": "ChangedFile store information about files affected by the pull request",
"type": "object", "type": "object",
@ -18326,6 +18461,26 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"FilesResponse": {
"description": "FilesResponse contains information about multiple files from a repo",
"type": "object",
"properties": {
"commit": {
"$ref": "#/definitions/FileCommitResponse"
},
"files": {
"type": "array",
"items": {
"$ref": "#/definitions/ContentsResponse"
},
"x-go-name": "Files"
},
"verification": {
"$ref": "#/definitions/PayloadCommitVerification"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"GPGKey": { "GPGKey": {
"description": "GPGKey a user GPG key to sign commit and tag in repository", "description": "GPGKey a user GPG key to sign commit and tag in repository",
"type": "object", "type": "object",
@ -21996,6 +22151,12 @@
"$ref": "#/definitions/FileResponse" "$ref": "#/definitions/FileResponse"
} }
}, },
"FilesResponse": {
"description": "FilesResponse",
"schema": {
"$ref": "#/definitions/FilesResponse"
}
},
"GPGKey": { "GPGKey": {
"description": "GPGKey", "description": "GPGKey",
"schema": { "schema": {

View file

@ -11,18 +11,22 @@ import (
files_service "code.gitea.io/gitea/services/repository/files" files_service "code.gitea.io/gitea/services/repository/files"
) )
func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FileResponse, error) { func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FilesResponse, error) {
opts := &files_service.UpdateRepoFileOptions{ opts := &files_service.ChangeRepoFilesOptions{
OldBranch: branchName, Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: treePath, TreePath: treePath,
Content: content, Content: content,
IsNewFile: true, },
},
OldBranch: branchName,
Author: nil, Author: nil,
Committer: nil, Committer: nil,
} }
return files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, user, opts) return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
} }
func createFile(user *user_model.User, repo *repo_model.Repository, treePath string) (*api.FileResponse, error) { func createFile(user *user_model.User, repo *repo_model.Repository, treePath string) (*api.FilesResponse, error) {
return createFileInBranch(user, repo, treePath, repo.DefaultBranch, "This is a NEW file") return createFileInBranch(user, repo, treePath, repo.DefaultBranch, "This is a NEW file")
} }

View file

@ -0,0 +1,309 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
stdCtx "context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
func getChangeFilesOptions() *api.ChangeFilesOptions {
newContent := "This is new text"
updateContent := "This is updated text"
newContentEncoded := base64.StdEncoding.EncodeToString([]byte(newContent))
updateContentEncoded := base64.StdEncoding.EncodeToString([]byte(updateContent))
return &api.ChangeFilesOptions{
FileOptions: api.FileOptions{
BranchName: "master",
NewBranchName: "master",
Message: "My update of new/file.txt",
Author: api.Identity{
Name: "Anne Doe",
Email: "annedoe@example.com",
},
Committer: api.Identity{
Name: "John Doe",
Email: "johndoe@example.com",
},
},
Files: []*api.ChangeFileOperation{
{
Operation: "create",
Content: newContentEncoded,
},
{
Operation: "update",
Content: updateContentEncoded,
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
},
{
Operation: "delete",
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
},
},
}
}
func TestAPIChangeFiles(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
fileID := 0
// Get user2's token
session := loginUser(t, user2.Name)
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
// Get user4's token
session = loginUser(t, user4.Name)
token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
// Test changing files in repo1 which user2 owns, try both with branch and empty branch
for _, branch := range [...]string{
"master", // Branch
"", // Empty branch
} {
fileID++
createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
changeFilesOptions := getChangeFilesOptions()
changeFilesOptions.BranchName = branch
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url := fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user2.Name, repo1.Name, token2)
req := NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
resp := MakeRequest(t, req, http.StatusCreated)
gitRepo, _ := git.OpenRepository(stdCtx.Background(), repo1.RepoPath())
commitID, _ := gitRepo.GetBranchCommitID(changeFilesOptions.NewBranchName)
createLasCommit, _ := gitRepo.GetCommitByPath(createTreePath)
updateLastCommit, _ := gitRepo.GetCommitByPath(updateTreePath)
expectedCreateFileResponse := getExpectedFileResponseForCreate(fmt.Sprintf("%v/%v", user2.Name, repo1.Name), commitID, createTreePath, createLasCommit.ID.String())
expectedUpdateFileResponse := getExpectedFileResponseForUpdate(commitID, updateTreePath, updateLastCommit.ID.String())
var filesResponse api.FilesResponse
DecodeJSON(t, resp, &filesResponse)
// check create file
assert.EqualValues(t, expectedCreateFileResponse.Content, filesResponse.Files[0])
// check update file
assert.EqualValues(t, expectedUpdateFileResponse.Content, filesResponse.Files[1])
// test commit info
assert.EqualValues(t, expectedCreateFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.EqualValues(t, expectedCreateFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Email, filesResponse.Commit.Committer.Email)
assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Name, filesResponse.Commit.Committer.Name)
// test delete file
assert.Nil(t, filesResponse.Files[2])
gitRepo.Close()
}
// Test changing files in a new branch
changeFilesOptions := getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "new_branch"
fileID++
createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url := fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user2.Name, repo1.Name, token2)
req := NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
resp := MakeRequest(t, req, http.StatusCreated)
var filesResponse api.FilesResponse
DecodeJSON(t, resp, &filesResponse)
expectedCreateSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf"
expectedCreateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID)
expectedCreateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID)
expectedUpdateSHA := "08bd14b2e2852529157324de9c226b3364e76136"
expectedUpdateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID)
expectedUpdateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID)
assert.EqualValues(t, expectedCreateSHA, filesResponse.Files[0].SHA)
assert.EqualValues(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.EqualValues(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL)
assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[1].SHA)
assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
assert.Nil(t, filesResponse.Files[2])
assert.EqualValues(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
// Test updating a file and renaming it
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
fileID++
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
changeFilesOptions.Files[0].FromPath = updateTreePath
changeFilesOptions.Files[0].Path = "rename/" + updateTreePath
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &filesResponse)
expectedUpdateSHA = "08bd14b2e2852529157324de9c226b3364e76136"
expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID)
expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID)
assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[0].SHA)
assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[0].DownloadURL)
// Test updating a file without a message
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Message = ""
changeFilesOptions.BranchName = repo1.DefaultBranch
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &filesResponse)
expectedMessage := fmt.Sprintf("Add %v\nUpdate %v\nDelete %v\n", createTreePath, updateTreePath, deleteTreePath)
assert.EqualValues(t, expectedMessage, filesResponse.Commit.Message)
// Test updating a file with the wrong SHA
fileID++
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
changeFilesOptions.Files[0].Path = updateTreePath
correctSHA := changeFilesOptions.Files[0].SHA
changeFilesOptions.Files[0].SHA = "badsha"
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
expectedAPIError := context.APIError{
Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]",
URL: setting.API.SwaggerURL,
}
var apiError context.APIError
DecodeJSON(t, resp, &apiError)
assert.Equal(t, expectedAPIError, apiError)
// Test creating a file in repo1 by user4 who does not have write access
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user2.Name, repo16.Name, token4)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusNotFound)
// Tests a repo with no token given so will fail
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusNotFound)
// Test using access token for a private repo that the user of the token owns
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user2.Name, repo16.Name, token2)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusCreated)
// Test using org repo "user3/repo3" where user2 is a collaborator
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user3, repo3, updateTreePath)
createFile(user3, repo3, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user3.Name, repo3.Name, token2)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusCreated)
// Test using org repo "user3/repo3" with no user token
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user3, repo3, updateTreePath)
createFile(user3, repo3, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents", user3.Name, repo3.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusNotFound)
// Test using repo "user2/repo1" where user4 is a NOT collaborator
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents?token=%s", user2.Name, repo1.Name, token4)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions)
MakeRequest(t, req, http.StatusForbidden)
})
}

View file

@ -367,22 +367,30 @@ func TestConflictChecking(t *testing.T) {
assert.NotEmpty(t, baseRepo) assert.NotEmpty(t, baseRepo)
// create a commit on new branch. // create a commit on new branch.
_, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, user, &files_service.UpdateRepoFileOptions{ _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "important_file", TreePath: "important_file",
Message: "Add a important file",
Content: "Just a non-important file", Content: "Just a non-important file",
IsNewFile: true, },
},
Message: "Add a important file",
OldBranch: "main", OldBranch: "main",
NewBranch: "important-secrets", NewBranch: "important-secrets",
}) })
assert.NoError(t, err) assert.NoError(t, err)
// create a commit on main branch. // create a commit on main branch.
_, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, user, &files_service.UpdateRepoFileOptions{ _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "important_file", TreePath: "important_file",
Message: "Add a important file",
Content: "Not the same content :P", Content: "Not the same content :P",
IsNewFile: true, },
},
Message: "Add a important file",
OldBranch: "main", OldBranch: "main",
NewBranch: "main", NewBranch: "main",
}) })

View file

@ -101,11 +101,15 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
assert.NotEmpty(t, headRepo) assert.NotEmpty(t, headRepo)
// create a commit on base Repo // create a commit on base Repo
_, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, actor, &files_service.UpdateRepoFileOptions{ _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, actor, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "File_A", TreePath: "File_A",
Message: "Add File A",
Content: "File A", Content: "File A",
IsNewFile: true, },
},
Message: "Add File A",
OldBranch: "master", OldBranch: "master",
NewBranch: "master", NewBranch: "master",
Author: &files_service.IdentityOptions{ Author: &files_service.IdentityOptions{
@ -124,11 +128,15 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
assert.NoError(t, err) assert.NoError(t, err)
// create a commit on head Repo // create a commit on head Repo
_, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, headRepo, actor, &files_service.UpdateRepoFileOptions{ _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, actor, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "File_B", TreePath: "File_B",
Message: "Add File on PR branch",
Content: "File B", Content: "File B",
IsNewFile: true, },
},
Message: "Add File on PR branch",
OldBranch: "master", OldBranch: "master",
NewBranch: "newBranch", NewBranch: "newBranch",
Author: &files_service.IdentityOptions{ Author: &files_service.IdentityOptions{

View file

@ -10,6 +10,7 @@ import (
"time" "time"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
@ -19,33 +20,90 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func getCreateRepoFileOptions(repo *repo_model.Repository) *files_service.UpdateRepoFileOptions { func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
return &files_service.UpdateRepoFileOptions{ return &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "new/file.txt",
Content: "This is a NEW file",
},
},
OldBranch: repo.DefaultBranch, OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch, NewBranch: repo.DefaultBranch,
TreePath: "new/file.txt",
Message: "Creates new/file.txt", Message: "Creates new/file.txt",
Content: "This is a NEW file",
IsNewFile: true,
Author: nil, Author: nil,
Committer: nil, Committer: nil,
} }
} }
func getUpdateRepoFileOptions(repo *repo_model.Repository) *files_service.UpdateRepoFileOptions { func getUpdateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
return &files_service.UpdateRepoFileOptions{ return &files_service.ChangeRepoFilesOptions{
OldBranch: repo.DefaultBranch, Files: []*files_service.ChangeRepoFile{
NewBranch: repo.DefaultBranch, {
Operation: "update",
TreePath: "README.md", TreePath: "README.md",
Message: "Updates README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
Content: "This is UPDATED content for the README file", Content: "This is UPDATED content for the README file",
IsNewFile: false, },
},
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Updates README.md",
Author: nil, Author: nil,
Committer: nil, Committer: nil,
} }
} }
func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
return &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
},
},
LastCommitID: "",
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Deletes README.md",
Author: &files_service.IdentityOptions{
Name: "Bob Smith",
Email: "bob@smith.com",
},
Committer: nil,
}
}
func getExpectedFileResponseForRepofilesDelete(u *url.URL) *api.FileResponse {
// Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined
return &api.FileResponse{
Content: nil,
Commit: &api.FileCommitResponse{
Author: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Message: "Deletes README.md\n",
},
Verification: &api.PayloadCommitVerification{
Verified: false,
Reason: "gpg.error.not_signed_commit",
Signature: "",
Payload: "",
},
}
}
func getExpectedFileResponseForRepofilesCreate(commitID, lastCommitSHA string) *api.FileResponse { func getExpectedFileResponseForRepofilesCreate(commitID, lastCommitSHA string) *api.FileResponse {
treePath := "new/file.txt" treePath := "new/file.txt"
encoding := "base64" encoding := "base64"
@ -183,7 +241,7 @@ func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA
} }
} }
func TestCreateOrUpdateRepoFileForCreate(t *testing.T) { func TestChangeRepoFilesForCreate(t *testing.T) {
// setup // setup
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx := test.MockContext(t, "user2/repo1") ctx := test.MockContext(t, "user2/repo1")
@ -196,10 +254,10 @@ func TestCreateOrUpdateRepoFileForCreate(t *testing.T) {
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
doer := ctx.Doer doer := ctx.Doer
opts := getCreateRepoFileOptions(repo) opts := getCreateRepoFilesOptions(repo)
// test // test
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
// asserts // asserts
assert.NoError(t, err) assert.NoError(t, err)
@ -211,16 +269,16 @@ func TestCreateOrUpdateRepoFileForCreate(t *testing.T) {
expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String()) expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String())
assert.NotNil(t, expectedFileResponse) assert.NotNil(t, expectedFileResponse)
if expectedFileResponse != nil { if expectedFileResponse != nil {
assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
} }
}) })
} }
func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) { func TestChangeRepoFilesForUpdate(t *testing.T) {
// setup // setup
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx := test.MockContext(t, "user2/repo1") ctx := test.MockContext(t, "user2/repo1")
@ -233,10 +291,10 @@ func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) {
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
doer := ctx.Doer doer := ctx.Doer
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
// test // test
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
// asserts // asserts
assert.NoError(t, err) assert.NoError(t, err)
@ -244,17 +302,17 @@ func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) {
defer gitRepo.Close() defer gitRepo.Close()
commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
lastCommit, _ := commit.GetCommitByPath(opts.TreePath) lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String()) expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
}) })
} }
func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) { func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) {
// setup // setup
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx := test.MockContext(t, "user2/repo1") ctx := test.MockContext(t, "user2/repo1")
@ -267,12 +325,12 @@ func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) {
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
doer := ctx.Doer doer := ctx.Doer
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.FromTreePath = "README.md" opts.Files[0].FromTreePath = "README.md"
opts.TreePath = "README_new.md" // new file name, README_new.md opts.Files[0].TreePath = "README_new.md" // new file name, README_new.md
// test // test
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
// asserts // asserts
assert.NoError(t, err) assert.NoError(t, err)
@ -280,32 +338,32 @@ func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) {
defer gitRepo.Close() defer gitRepo.Close()
commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
lastCommit, _ := commit.GetCommitByPath(opts.TreePath) lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String()) expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
// assert that the old file no longer exists in the last commit of the branch // assert that the old file no longer exists in the last commit of the branch
fromEntry, err := commit.GetTreeEntryByPath(opts.FromTreePath) fromEntry, err := commit.GetTreeEntryByPath(opts.Files[0].FromTreePath)
switch err.(type) { switch err.(type) {
case git.ErrNotExist: case git.ErrNotExist:
// correct, continue // correct, continue
default: default:
t.Fatalf("expected git.ErrNotExist, got:%v", err) t.Fatalf("expected git.ErrNotExist, got:%v", err)
} }
toEntry, err := commit.GetTreeEntryByPath(opts.TreePath) toEntry, err := commit.GetTreeEntryByPath(opts.Files[0].TreePath)
assert.NoError(t, err) assert.NoError(t, err)
assert.Nil(t, fromEntry) // Should no longer exist here assert.Nil(t, fromEntry) // Should no longer exist here
assert.NotNil(t, toEntry) // Should exist here assert.NotNil(t, toEntry) // Should exist here
// assert SHA has remained the same but paths use the new file name // assert SHA has remained the same but paths use the new file name
assert.EqualValues(t, expectedFileResponse.Content.SHA, fileResponse.Content.SHA) assert.EqualValues(t, expectedFileResponse.Content.SHA, filesResponse.Files[0].SHA)
assert.EqualValues(t, expectedFileResponse.Content.Name, fileResponse.Content.Name) assert.EqualValues(t, expectedFileResponse.Content.Name, filesResponse.Files[0].Name)
assert.EqualValues(t, expectedFileResponse.Content.Path, fileResponse.Content.Path) assert.EqualValues(t, expectedFileResponse.Content.Path, filesResponse.Files[0].Path)
assert.EqualValues(t, expectedFileResponse.Content.URL, fileResponse.Content.URL) assert.EqualValues(t, expectedFileResponse.Content.URL, filesResponse.Files[0].URL)
assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
}) })
} }
// Test opts with branch names removed, should get same results as above test // Test opts with branch names removed, should get same results as above test
func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) { func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
// setup // setup
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx := test.MockContext(t, "user2/repo1") ctx := test.MockContext(t, "user2/repo1")
@ -318,12 +376,12 @@ func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) {
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
doer := ctx.Doer doer := ctx.Doer
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.OldBranch = "" opts.OldBranch = ""
opts.NewBranch = "" opts.NewBranch = ""
// test // test
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
// asserts // asserts
assert.NoError(t, err) assert.NoError(t, err)
@ -331,13 +389,86 @@ func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) {
defer gitRepo.Close() defer gitRepo.Close()
commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch) commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch)
lastCommit, _ := commit.GetCommitByPath(opts.TreePath) lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String()) expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
}) })
} }
func TestCreateOrUpdateRepoFileErrors(t *testing.T) { func TestChangeRepoFilesForDelete(t *testing.T) {
onGiteaRun(t, testDeleteRepoFiles)
}
func testDeleteRepoFiles(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
test.LoadRepo(t, ctx, 1)
test.LoadRepoCommit(t, ctx)
test.LoadUser(t, ctx, 2)
test.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
t.Run("Delete README.md file", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepofilesDelete(u)
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification)
})
t.Run("Verify README.md has been deleted", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, filesResponse)
expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]"
assert.EqualError(t, err, expectedError)
})
}
// Test opts with branch names removed, same results
func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) {
onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames)
}
func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
test.LoadRepo(t, ctx, 1)
test.LoadRepoCommit(t, ctx)
test.LoadUser(t, ctx, 2)
test.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
opts.OldBranch = ""
opts.NewBranch = ""
t.Run("Delete README.md without Branch Name", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepofilesDelete(u)
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification)
})
}
func TestChangeRepoFilesErrors(t *testing.T) {
// setup // setup
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx := test.MockContext(t, "user2/repo1") ctx := test.MockContext(t, "user2/repo1")
@ -352,63 +483,63 @@ func TestCreateOrUpdateRepoFileErrors(t *testing.T) {
doer := ctx.Doer doer := ctx.Doer
t.Run("bad branch", func(t *testing.T) { t.Run("bad branch", func(t *testing.T) {
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.OldBranch = "bad_branch" opts.OldBranch = "bad_branch"
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, fileResponse) assert.Nil(t, filesResponse)
expectedError := "branch does not exist [name: " + opts.OldBranch + "]" expectedError := "branch does not exist [name: " + opts.OldBranch + "]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("bad SHA", func(t *testing.T) { t.Run("bad SHA", func(t *testing.T) {
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
origSHA := opts.SHA origSHA := opts.Files[0].SHA
opts.SHA = "bad_sha" opts.Files[0].SHA = "bad_sha"
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse) assert.Nil(t, filesResponse)
assert.Error(t, err) assert.Error(t, err)
expectedError := "sha does not match [given: " + opts.SHA + ", expected: " + origSHA + "]" expectedError := "sha does not match [given: " + opts.Files[0].SHA + ", expected: " + origSHA + "]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("new branch already exists", func(t *testing.T) { t.Run("new branch already exists", func(t *testing.T) {
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.NewBranch = "develop" opts.NewBranch = "develop"
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse) assert.Nil(t, filesResponse)
assert.Error(t, err) assert.Error(t, err)
expectedError := "branch already exists [name: " + opts.NewBranch + "]" expectedError := "branch already exists [name: " + opts.NewBranch + "]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("treePath is empty:", func(t *testing.T) { t.Run("treePath is empty:", func(t *testing.T) {
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.TreePath = "" opts.Files[0].TreePath = ""
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse) assert.Nil(t, filesResponse)
assert.Error(t, err) assert.Error(t, err)
expectedError := "path contains a malformed path component [path: ]" expectedError := "path contains a malformed path component [path: ]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("treePath is a git directory:", func(t *testing.T) { t.Run("treePath is a git directory:", func(t *testing.T) {
opts := getUpdateRepoFileOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.TreePath = ".git" opts.Files[0].TreePath = ".git"
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse) assert.Nil(t, filesResponse)
assert.Error(t, err) assert.Error(t, err)
expectedError := "path contains a malformed path component [path: " + opts.TreePath + "]" expectedError := "path contains a malformed path component [path: " + opts.Files[0].TreePath + "]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("create file that already exists", func(t *testing.T) { t.Run("create file that already exists", func(t *testing.T) {
opts := getCreateRepoFileOptions(repo) opts := getCreateRepoFilesOptions(repo)
opts.TreePath = "README.md" // already exists opts.Files[0].TreePath = "README.md" // already exists
fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) fileResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse) assert.Nil(t, fileResponse)
assert.Error(t, err) assert.Error(t, err)
expectedError := "repository file already exists [path: " + opts.TreePath + "]" expectedError := "repository file already exists [path: " + opts.Files[0].TreePath + "]"
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
}) })

View file

@ -1,201 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/url"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/assert"
)
func getDeleteRepoFileOptions(repo *repo_model.Repository) *files_service.DeleteRepoFileOptions {
return &files_service.DeleteRepoFileOptions{
LastCommitID: "",
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
TreePath: "README.md",
Message: "Deletes README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
Author: &files_service.IdentityOptions{
Name: "Bob Smith",
Email: "bob@smith.com",
},
Committer: nil,
}
}
func getExpectedDeleteFileResponse(u *url.URL) *api.FileResponse {
// Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined
return &api.FileResponse{
Content: nil,
Commit: &api.FileCommitResponse{
Author: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Message: "Deletes README.md\n",
},
Verification: &api.PayloadCommitVerification{
Verified: false,
Reason: "gpg.error.not_signed_commit",
Signature: "",
Payload: "",
},
}
}
func TestDeleteRepoFile(t *testing.T) {
onGiteaRun(t, testDeleteRepoFile)
}
func testDeleteRepoFile(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
test.LoadRepo(t, ctx, 1)
test.LoadRepoCommit(t, ctx)
test.LoadUser(t, ctx, 2)
test.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFileOptions(repo)
t.Run("Delete README.md file", func(t *testing.T) {
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedDeleteFileResponse(u)
assert.NotNil(t, fileResponse)
assert.Nil(t, fileResponse.Content)
assert.EqualValues(t, expectedFileResponse.Commit.Message, fileResponse.Commit.Message)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, fileResponse.Commit.Author.Identity)
assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, fileResponse.Commit.Committer.Identity)
assert.EqualValues(t, expectedFileResponse.Verification, fileResponse.Verification)
})
t.Run("Verify README.md has been deleted", func(t *testing.T) {
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse)
expectedError := "repository file does not exist [path: " + opts.TreePath + "]"
assert.EqualError(t, err, expectedError)
})
}
// Test opts with branch names removed, same results
func TestDeleteRepoFileWithoutBranchNames(t *testing.T) {
onGiteaRun(t, testDeleteRepoFileWithoutBranchNames)
}
func testDeleteRepoFileWithoutBranchNames(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
test.LoadRepo(t, ctx, 1)
test.LoadRepoCommit(t, ctx)
test.LoadUser(t, ctx, 2)
test.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFileOptions(repo)
opts.OldBranch = ""
opts.NewBranch = ""
t.Run("Delete README.md without Branch Name", func(t *testing.T) {
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedDeleteFileResponse(u)
assert.NotNil(t, fileResponse)
assert.Nil(t, fileResponse.Content)
assert.EqualValues(t, expectedFileResponse.Commit.Message, fileResponse.Commit.Message)
assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, fileResponse.Commit.Author.Identity)
assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, fileResponse.Commit.Committer.Identity)
assert.EqualValues(t, expectedFileResponse.Verification, fileResponse.Verification)
})
}
func TestDeleteRepoFileErrors(t *testing.T) {
// setup
unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
test.LoadRepo(t, ctx, 1)
test.LoadRepoCommit(t, ctx)
test.LoadUser(t, ctx, 2)
test.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
t.Run("Bad branch", func(t *testing.T) {
opts := getDeleteRepoFileOptions(repo)
opts.OldBranch = "bad_branch"
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Error(t, err)
assert.Nil(t, fileResponse)
expectedError := "branch does not exist [name: " + opts.OldBranch + "]"
assert.EqualError(t, err, expectedError)
})
t.Run("Bad SHA", func(t *testing.T) {
opts := getDeleteRepoFileOptions(repo)
origSHA := opts.SHA
opts.SHA = "bad_sha"
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse)
assert.Error(t, err)
expectedError := "sha does not match [given: " + opts.SHA + ", expected: " + origSHA + "]"
assert.EqualError(t, err, expectedError)
})
t.Run("New branch already exists", func(t *testing.T) {
opts := getDeleteRepoFileOptions(repo)
opts.NewBranch = "develop"
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse)
assert.Error(t, err)
expectedError := "branch already exists [name: " + opts.NewBranch + "]"
assert.EqualError(t, err, expectedError)
})
t.Run("TreePath is empty:", func(t *testing.T) {
opts := getDeleteRepoFileOptions(repo)
opts.TreePath = ""
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse)
assert.Error(t, err)
expectedError := "path contains a malformed path component [path: ]"
assert.EqualError(t, err, expectedError)
})
t.Run("TreePath is a git directory:", func(t *testing.T) {
opts := getDeleteRepoFileOptions(repo)
opts.TreePath = ".git"
fileResponse, err := files_service.DeleteRepoFile(git.DefaultContext, repo, doer, opts)
assert.Nil(t, fileResponse)
assert.Error(t, err)
expectedError := "path contains a malformed path component [path: " + opts.TreePath + "]"
assert.EqualError(t, err, expectedError)
})
}