]> source.dussan.org Git - gitea.git/commitdiff
API endpoint for changing/creating/deleting multiple files (#24887)
authorDenys Konovalov <privat@denyskon.de>
Mon, 29 May 2023 09:41:35 +0000 (11:41 +0200)
committerGitHub <noreply@github.com>
Mon, 29 May 2023 09:41:35 +0000 (17:41 +0800)
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

17 files changed:
modules/structs/repo_file.go
routers/api/v1/api.go
routers/api/v1/repo/file.go
routers/api/v1/swagger/options.go
routers/api/v1/swagger/repo.go
routers/web/repo/editor.go
services/repository/files/delete.go [deleted file]
services/repository/files/file.go
services/repository/files/update.go
templates/swagger/v1_json.tmpl
tests/integration/api_repo_file_helpers.go
tests/integration/api_repo_files_change_test.go [new file with mode: 0644]
tests/integration/pull_merge_test.go
tests/integration/pull_update_test.go
tests/integration/repofiles_change_test.go [new file with mode: 0644]
tests/integration/repofiles_delete_test.go [deleted file]
tests/integration/repofiles_update_test.go [deleted file]

index 328d7e47c8e8bd88dc1faf97d17ec98237bf50d0..6ca0e1c10190228819d10de70706420f69f34186 100644 (file)
@@ -64,6 +64,35 @@ func (o *UpdateFileOptions) Branch() string {
        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
 type FileOptionInterface interface {
        Branch() string
@@ -126,6 +155,13 @@ type FileResponse struct {
        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
 type FileDeleteResponse struct {
        Content      interface{}                `json:"content"` // to be set to nil
index fccfc5792ca7025f75c1dffabfd66d17fa0136fe..45e36e84fe788ce13a7acaca1493bd8ee42fda9a 100644 (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.Group("/contents", func() {
                                        m.Get("", repo.GetContentsList)
+                                       m.Post("", reqToken(auth_model.AccessTokenScopeRepo), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles)
                                        m.Get("/*", repo.GetContents)
                                        m.Group("/*", func() {
                                                m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile)
index 786407827c9d8c09ace1f6047b03f33c82e8615a..ae0d31c2a6d792d473a71000ad75f2b96b6383ff 100644 (file)
@@ -12,6 +12,7 @@ import (
        "io"
        "net/http"
        "path"
+       "strings"
        "time"
 
        "code.gitea.io/gitea/models"
@@ -407,6 +408,96 @@ func canReadFiles(r *context.Repository) bool {
        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
 func CreateFile(ctx *context.APIContext) {
        // 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
        }
 
-       opts := &files_service.UpdateRepoFileOptions{
-               Content:   apiOpts.Content,
-               IsNewFile: true,
+       opts := &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "create",
+                               TreePath:  ctx.Params("*"),
+                               Content:   apiOpts.Content,
+                       },
+               },
                Message:   apiOpts.Message,
-               TreePath:  ctx.Params("*"),
                OldBranch: apiOpts.BranchName,
                NewBranch: apiOpts.NewBranchName,
                Committer: &files_service.IdentityOptions{
@@ -482,12 +577,13 @@ func CreateFile(ctx *context.APIContext) {
        }
 
        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)
        } else {
+               fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
                ctx.JSON(http.StatusCreated, fileResponse)
        }
 }
@@ -540,15 +636,19 @@ func UpdateFile(ctx *context.APIContext) {
                apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
        }
 
-       opts := &files_service.UpdateRepoFileOptions{
-               Content:      apiOpts.Content,
-               SHA:          apiOpts.SHA,
-               IsNewFile:    false,
-               Message:      apiOpts.Message,
-               FromTreePath: apiOpts.FromPath,
-               TreePath:     ctx.Params("*"),
-               OldBranch:    apiOpts.BranchName,
-               NewBranch:    apiOpts.NewBranchName,
+       opts := &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation:    "update",
+                               Content:      apiOpts.Content,
+                               SHA:          apiOpts.SHA,
+                               FromTreePath: apiOpts.FromPath,
+                               TreePath:     ctx.Params("*"),
+                       },
+               },
+               Message:   apiOpts.Message,
+               OldBranch: apiOpts.BranchName,
+               NewBranch: apiOpts.NewBranchName,
                Committer: &files_service.IdentityOptions{
                        Name:  apiOpts.Committer.Name,
                        Email: apiOpts.Committer.Email,
@@ -571,12 +671,13 @@ func UpdateFile(ctx *context.APIContext) {
        }
 
        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)
        } else {
+               fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
                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
-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) {
                return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{
                        UserID:   ctx.Doer.ID,
@@ -608,13 +709,45 @@ func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoF
                }
        }
 
-       content, err := base64.StdEncoding.DecodeString(opts.Content)
-       if err != nil {
-               return nil, err
+       for _, file := range opts.Files {
+               content, err := base64.StdEncoding.DecodeString(file.Content)
+               if err != nil {
+                       return nil, err
+               }
+               file.Content = string(content)
        }
-       opts.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
@@ -670,12 +803,17 @@ func DeleteFile(ctx *context.APIContext) {
                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,
                OldBranch: apiOpts.BranchName,
                NewBranch: apiOpts.NewBranchName,
-               SHA:       apiOpts.SHA,
-               TreePath:  ctx.Params("*"),
                Committer: &files_service.IdentityOptions{
                        Name:  apiOpts.Committer.Name,
                        Email: apiOpts.Committer.Email,
@@ -698,10 +836,10 @@ func DeleteFile(ctx *context.APIContext) {
        }
 
        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) {
                        ctx.Error(http.StatusNotFound, "DeleteFile", err)
                        return
@@ -718,6 +856,7 @@ func DeleteFile(ctx *context.APIContext) {
                }
                ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
        } else {
+               fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
                ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
        }
 }
index 09bb1d18f3ae03782e13240a5ff653b56ef2eede..353d32e2142eebcf9ca0c453554021f678ab6d9b 100644 (file)
@@ -116,6 +116,9 @@ type swaggerParameterBodies struct {
        // in:body
        EditAttachmentOptions api.EditAttachmentOptions
 
+       // in:body
+       ChangeFilesOptions api.ChangeFilesOptions
+
        // in:body
        CreateFileOptions api.CreateFileOptions
 
index 10056ac8cbb89f1b15f53bbdf8b71740ebceedac..3e23aa4d5a5ac23bee346819f956b01dff9ee667 100644 (file)
@@ -296,6 +296,13 @@ type swaggerFileResponse struct {
        Body api.FileResponse `json:"body"`
 }
 
+// FilesResponse
+// swagger:response FilesResponse
+type swaggerFilesResponse struct {
+       // in: body
+       Body api.FilesResponse `json:"body"`
+}
+
 // ContentsResponse
 // swagger:response ContentsResponse
 type swaggerContentsResponse struct {
index b94aa1b7ba0517c2526c5842e7ec1340cd4464e7..7433a0a56b16c24b521508132219ad2c29577933 100644 (file)
@@ -272,18 +272,27 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
                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,
                OldBranch:    ctx.Repo.BranchName,
                NewBranch:    branchName,
-               FromTreePath: ctx.Repo.TreePath,
-               TreePath:     form.TreePath,
                Message:      message,
-               Content:      strings.ReplaceAll(form.Content, "\r", ""),
-               IsNewFile:    isNewFile,
-               Signoff:      form.Signoff,
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation:    operation,
+                               FromTreePath: ctx.Repo.TreePath,
+                               TreePath:     form.TreePath,
+                               Content:      strings.ReplaceAll(form.Content, "\r", ""),
+                       },
+               },
+               Signoff: form.Signoff,
        }); 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) {
                        ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form)
                } else if git_model.IsErrLFSFileLocked(err) {
@@ -478,13 +487,18 @@ func DeleteFilePost(ctx *context.Context) {
                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,
                OldBranch:    ctx.Repo.BranchName,
                NewBranch:    branchName,
-               TreePath:     ctx.Repo.TreePath,
-               Message:      message,
-               Signoff:      form.Signoff,
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "delete",
+                               TreePath:  ctx.Repo.TreePath,
+                       },
+               },
+               Message: message,
+               Signoff: form.Signoff,
        }); err != nil {
                // This is where we handle all the errors thrown by repofiles.DeleteRepoFile
                if git.IsErrNotExist(err) || models.IsErrRepoFileDoesNotExist(err) {
diff --git a/services/repository/files/delete.go b/services/repository/files/delete.go
deleted file mode 100644 (file)
index faa60bb..0000000
+++ /dev/null
@@ -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
-}
index dc1e547dcdae9014739d28ff5170efde1aff06c3..16783f5b5f8a9519d1722797e0a43c14e55142fa 100644 (file)
@@ -17,6 +17,22 @@ import (
        "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
 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
@@ -30,6 +46,20 @@ func GetFileResponseFromCommit(ctx context.Context, repo *repo_model.Repository,
        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
 func GetFileCommitResponse(repo *repo_model.Repository, commit *git.Commit) (*api.FileCommitResponse, error) {
        if repo == nil {
index 25014f441833ef5a10e3294446045f3843e40c0e..81d5e32773a65a8f59aab513850a809506772e0c 100644 (file)
@@ -41,23 +41,36 @@ type CommitDateOptions struct {
        Committer time.Time
 }
 
-// UpdateRepoFileOptions holds the repository file update options
-type UpdateRepoFileOptions struct {
-       LastCommitID string
-       OldBranch    string
-       NewBranch    string
+type ChangeRepoFile struct {
+       Operation    string
        TreePath     string
        FromTreePath string
-       Message      string
        Content      string
        SHA          string
-       IsNewFile    bool
+       Options      *RepoFileOptions
+}
+
+// UpdateRepoFilesOptions holds the repository files update options
+type ChangeRepoFilesOptions struct {
+       LastCommitID string
+       OldBranch    string
+       NewBranch    string
+       Message      string
+       Files        []*ChangeRepoFile
        Author       *IdentityOptions
        Committer    *IdentityOptions
        Dates        *CommitDateOptions
        Signoff      bool
 }
 
+type RepoFileOptions struct {
+       treePath     string
+       fromTreePath string
+       encoding     string
+       bom          bool
+       executable   bool
+}
+
 func detectEncodingAndBOM(entry *git.TreeEntry, repo *repo_model.Repository) (string, bool) {
        reader, err := entry.Blob().DataAsync()
        if err != nil {
@@ -125,8 +138,8 @@ func detectEncodingAndBOM(entry *git.TreeEntry, repo *repo_model.Repository) (st
        return encoding, false
 }
 
-// CreateOrUpdateRepoFile adds or updates a file in the given repository
-func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *UpdateRepoFileOptions) (*structs.FileResponse, error) {
+// ChangeRepoFiles adds, updates or removes multiple files in the given repository
+func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (*structs.FilesResponse, error) {
        // If no branch name is set, assume default branch
        if opts.OldBranch == "" {
                opts.OldBranch = repo.DefaultBranch
@@ -146,6 +159,38 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
                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.
        // 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
@@ -159,30 +204,10 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
                if err != nil && !git.IsErrBranchNotExist(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
        }
 
-       // 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)
 
        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()
        hasOldBranch := true
        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 {
                        return nil, err
                }
@@ -209,9 +239,29 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
                }
        }
 
-       encoding := "UTF-8"
-       bom := false
-       executable := false
+       for _, file := range opts.Files {
+               if file.Operation == "delete" {
+                       // 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 {
                // Get the commit of the original branch
@@ -232,48 +282,114 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
 
                }
 
-               if !opts.IsNewFile {
-                       fromEntry, err := commit.GetTreeEntryByPath(fromTreePath)
-                       if err != nil {
+               for _, file := range opts.Files {
+                       if err := handleCheckErrors(file, commit, opts, repo); 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 != fromEntry.ID.String() {
-                                       return nil, models.ErrSHADoesNotMatch{
-                                               Path:       treePath,
-                                               GivenSHA:   opts.SHA,
-                                               CurrentSHA: fromEntry.ID.String(),
-                                       }
+               }
+       }
+
+       contentStore := lfs.NewContentStore()
+       for _, file := range opts.Files {
+               switch file.Operation {
+               case "create", "update":
+                       if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil {
+                               return nil, err
+                       }
+               case "delete":
+                       // Remove the file from the index
+                       if err := t.RemoveFilesFromIndex(file.TreePath); err != nil {
+                               return nil, err
+                       }
+               default:
+                       return nil, fmt.Errorf("Invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
+               }
+       }
+
+       // Now write the tree
+       treeHash, err := t.WriteTree()
+       if err != nil {
+               return nil, err
+       }
+
+       // Now commit the tree
+       var commitHash string
+       if opts.Dates != nil {
+               commitHash, err = t.CommitTreeWithDate(opts.LastCommitID, author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer)
+       } else {
+               commitHash, err = t.CommitTree(opts.LastCommitID, 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 {
+               log.Error("%T %v", err, err)
+               return nil, err
+       }
+
+       commit, err := t.GetCommit(commitHash)
+       if err != nil {
+               return nil, err
+       }
+
+       filesReponse, err := GetFilesResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePaths)
+       if err != nil {
+               return nil, err
+       }
+
+       if repo.IsEmpty {
+               _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false}, "is_empty")
+       }
+
+       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(treePath, opts.LastCommitID); err != nil {
-                                               return nil, err
-                                       } else if changed {
-                                               return nil, models.ErrCommitIDDoesNotMatch{
-                                                       GivenCommitID:   opts.LastCommitID,
-                                                       CurrentCommitID: opts.LastCommitID,
-                                               }
+                       }
+               } 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 nil, models.ErrSHAOrCommitIDNotProvided{}
+                               // The file wasn't modified, so we are good to delete it
                        }
-                       encoding, bom = detectEncodingAndBOM(fromEntry, repo)
-                       executable = fromEntry.IsExecutable()
+               } 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(treePath, "/")
+               treePathParts := strings.Split(file.Options.treePath, "/")
                subTreePath := ""
                for index, part := range treePathParts {
                        subTreePath = path.Join(subTreePath, part)
@@ -283,11 +399,11 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
                                        // Means there is no item with that name, so we're good
                                        break
                                }
-                               return nil, err
+                               return err
                        }
                        if index < len(treePathParts)-1 {
                                if !entry.IsDir() {
-                                       return nil, models.ErrFilePathInvalid{
+                                       return models.ErrFilePathInvalid{
                                                Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
                                                Path:    subTreePath,
                                                Name:    part,
@@ -295,193 +411,170 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do
                                        }
                                }
                        } else if entry.IsLink() {
-                               return nil, models.ErrFilePathInvalid{
+                               return models.ErrFilePathInvalid{
                                        Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
                                        Path:    subTreePath,
                                        Name:    part,
                                        Type:    git.EntryModeSymlink,
                                }
                        } else if entry.IsDir() {
-                               return nil, models.ErrFilePathInvalid{
+                               return models.ErrFilePathInvalid{
                                        Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath),
                                        Path:    subTreePath,
                                        Name:    part,
                                        Type:    git.EntryModeTree,
                                }
-                       } else if fromTreePath != treePath || opts.IsNewFile {
+                       } 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 nil, models.ErrRepoFileAlreadyExists{
-                                       Path: treePath,
+                               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(opts.TreePath, opts.FromTreePath)
+       filesInIndex, err := t.LsFiles(file.TreePath, file.FromTreePath)
        if err != nil {
-               return nil, fmt.Errorf("UpdateRepoFile: %w", err)
+               return 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,
+       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 fromTreePath != treePath && len(filesInIndex) > 0 {
-               for _, file := range filesInIndex {
-                       if file == fromTreePath {
-                               if err := t.RemoveFilesFromIndex(opts.FromTreePath); err != nil {
-                                       return nil, err
+       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 := opts.Content
-       if bom {
+       content := file.Content
+       if file.Options.bom {
                content = string(charset.UTF8BOM) + content
        }
-       if encoding != "UTF-8" {
-               charsetEncoding, _ := stdcharset.Lookup(encoding)
+       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", opts.TreePath, opts.FromTreePath, encoding, err)
+                               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", encoding)
+                       log.Error("Unknown encoding: %s", file.Options.encoding)
                }
        }
        // Reset the opts.Content to our adjusted content to ensure that LFS gets the correct content
-       opts.Content = 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{treePath},
+                       Filenames:  []string{file.Options.treePath},
                        CachedOnly: true,
                })
                if err != nil {
-                       return nil, err
+                       return err
                }
 
-               if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" {
+               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(opts.Content))
+                       pointer, err := lfs.GeneratePointer(strings.NewReader(file.Content))
                        if err != nil {
-                               return nil, err
+                               return err
                        }
-                       lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repo.ID}
+                       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 nil, err
+               return err
        }
 
        // Add the object to the index
-       if executable {
-               if err := t.AddObjectToIndex("100755", objectHash, treePath); err != nil {
-                       return nil, err
+       if file.Options.executable {
+               if err := t.AddObjectToIndex("100755", objectHash, file.Options.treePath); err != nil {
+                       return err
                }
        } else {
-               if err := t.AddObjectToIndex("100644", objectHash, treePath); err != nil {
-                       return nil, err
+               if err := t.AddObjectToIndex("100644", objectHash, file.Options.treePath); err != nil {
+                       return 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(opts.LastCommitID, author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer)
-       } else {
-               commitHash, err = t.CommitTree(opts.LastCommitID, author, committer, treeHash, message, opts.Signoff)
-       }
-       if err != nil {
-               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
+                       return err
                }
-               contentStore := lfs.NewContentStore()
                exist, err := contentStore.Exists(lfsMetaObject.Pointer)
                if err != nil {
-                       return nil, err
+                       return 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)
+                       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 nil, err
+                               return err
                        }
                }
        }
 
-       // Then push this tree to NewBranch
-       if err := t.Push(doer, commitHash, opts.NewBranch); err != nil {
-               log.Error("%T %v", err, err)
-               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
-       }
-
-       if repo.IsEmpty {
-               _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false}, "is_empty")
-       }
-
-       return file, nil
+       return nil
 }
 
 // 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)
        if err != nil {
                return err
        }
        if protectedBranch != nil {
                protectedBranch.Repo = repo
-               isUnprotectedFile := false
-               glob := protectedBranch.GetUnprotectedFilePatterns()
-               if len(glob) != 0 {
-                       isUnprotectedFile = protectedBranch.IsUnprotectedFile(glob, treePath)
-               }
-               if !protectedBranch.CanUserPush(ctx, doer) && !isUnprotectedFile {
-                       return models.ErrUserCannotCommit{
-                               UserName: doer.LowerName,
+               globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
+               globProtected := protectedBranch.GetProtectedFilePatterns()
+               canUserPush := protectedBranch.CanUserPush(ctx, doer)
+               for _, treePath := range treePaths {
+                       isUnprotectedFile := false
+                       if len(globUnprotected) != 0 {
+                               isUnprotectedFile = protectedBranch.IsUnprotectedFile(globUnprotected, treePath)
+                       }
+                       if !canUserPush && !isUnprotectedFile {
+                               return models.ErrUserCannotCommit{
+                                       UserName: doer.LowerName,
+                               }
+                       }
+                       if protectedBranch.IsProtectedFile(globProtected, treePath) {
+                               return models.ErrFilePathProtected{
+                                       Path: treePath,
+                               }
                        }
                }
                if protectedBranch.RequireSignedCommits {
@@ -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
 }
index 15043e465f90ef27c782457e86905ec1e525a346..75492ab631610115ba8746a60fe93cb6eb1b0bab 100644 (file)
             "$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}": {
       },
       "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": {
       "description": "ChangedFile store information about files affected by the pull request",
       "type": "object",
       },
       "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": {
       "description": "GPGKey a user GPG key to sign commit and tag in repository",
       "type": "object",
         "$ref": "#/definitions/FileResponse"
       }
     },
+    "FilesResponse": {
+      "description": "FilesResponse",
+      "schema": {
+        "$ref": "#/definitions/FilesResponse"
+      }
+    },
     "GPGKey": {
       "description": "GPGKey",
       "schema": {
index d773bcd6293cc233bb71e30a1b4b50b619f26ca0..8e9b2bfeccf8d26848caee32665419502ae7f2c3 100644 (file)
@@ -11,18 +11,22 @@ import (
        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) {
-       opts := &files_service.UpdateRepoFileOptions{
+func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FilesResponse, error) {
+       opts := &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "create",
+                               TreePath:  treePath,
+                               Content:   content,
+                       },
+               },
                OldBranch: branchName,
-               TreePath:  treePath,
-               Content:   content,
-               IsNewFile: true,
                Author:    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")
 }
diff --git a/tests/integration/api_repo_files_change_test.go b/tests/integration/api_repo_files_change_test.go
new file mode 100644 (file)
index 0000000..38187ec
--- /dev/null
@@ -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)
+       })
+}
index 9271f25e5f0d53ecc760957454dd9833916ee143..f6a36f60af2be77c30920bf34f822d1f95dea998 100644 (file)
@@ -367,22 +367,30 @@ func TestConflictChecking(t *testing.T) {
                assert.NotEmpty(t, baseRepo)
 
                // create a commit on new branch.
-               _, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, user, &files_service.UpdateRepoFileOptions{
-                       TreePath:  "important_file",
+               _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
+                       Files: []*files_service.ChangeRepoFile{
+                               {
+                                       Operation: "create",
+                                       TreePath:  "important_file",
+                                       Content:   "Just a non-important file",
+                               },
+                       },
                        Message:   "Add a important file",
-                       Content:   "Just a non-important file",
-                       IsNewFile: true,
                        OldBranch: "main",
                        NewBranch: "important-secrets",
                })
                assert.NoError(t, err)
 
                // create a commit on main branch.
-               _, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, user, &files_service.UpdateRepoFileOptions{
-                       TreePath:  "important_file",
+               _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
+                       Files: []*files_service.ChangeRepoFile{
+                               {
+                                       Operation: "create",
+                                       TreePath:  "important_file",
+                                       Content:   "Not the same content :P",
+                               },
+                       },
                        Message:   "Add a important file",
-                       Content:   "Not the same content :P",
-                       IsNewFile: true,
                        OldBranch: "main",
                        NewBranch: "main",
                })
index 1b66656518a30df73dc6a7202cacaa58f37f3a04..b94731002f1648d86e4e8f0f0f07b57687a58b10 100644 (file)
@@ -101,11 +101,15 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
        assert.NotEmpty(t, headRepo)
 
        // create a commit on base Repo
-       _, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, baseRepo, actor, &files_service.UpdateRepoFileOptions{
-               TreePath:  "File_A",
+       _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, actor, &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "create",
+                               TreePath:  "File_A",
+                               Content:   "File A",
+                       },
+               },
                Message:   "Add File A",
-               Content:   "File A",
-               IsNewFile: true,
                OldBranch: "master",
                NewBranch: "master",
                Author: &files_service.IdentityOptions{
@@ -124,11 +128,15 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
        assert.NoError(t, err)
 
        // create a commit on head Repo
-       _, err = files_service.CreateOrUpdateRepoFile(git.DefaultContext, headRepo, actor, &files_service.UpdateRepoFileOptions{
-               TreePath:  "File_B",
+       _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, actor, &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "create",
+                               TreePath:  "File_B",
+                               Content:   "File B",
+                       },
+               },
                Message:   "Add File on PR branch",
-               Content:   "File B",
-               IsNewFile: true,
                OldBranch: "master",
                NewBranch: "newBranch",
                Author: &files_service.IdentityOptions{
diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go
new file mode 100644 (file)
index 0000000..a257b95
--- /dev/null
@@ -0,0 +1,546 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+       "net/url"
+       "path/filepath"
+       "testing"
+       "time"
+
+       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/setting"
+       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 getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
+       return &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "create",
+                               TreePath:  "new/file.txt",
+                               Content:   "This is a NEW file",
+                       },
+               },
+               OldBranch: repo.DefaultBranch,
+               NewBranch: repo.DefaultBranch,
+               Message:   "Creates new/file.txt",
+               Author:    nil,
+               Committer: nil,
+       }
+}
+
+func getUpdateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
+       return &files_service.ChangeRepoFilesOptions{
+               Files: []*files_service.ChangeRepoFile{
+                       {
+                               Operation: "update",
+                               TreePath:  "README.md",
+                               SHA:       "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
+                               Content:   "This is UPDATED content for the README file",
+                       },
+               },
+               OldBranch: repo.DefaultBranch,
+               NewBranch: repo.DefaultBranch,
+               Message:   "Updates README.md",
+               Author:    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 {
+       treePath := "new/file.txt"
+       encoding := "base64"
+       content := "VGhpcyBpcyBhIE5FVyBmaWxl"
+       selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
+       htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath
+       gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885"
+       downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath
+       return &api.FileResponse{
+               Content: &api.ContentsResponse{
+                       Name:          filepath.Base(treePath),
+                       Path:          treePath,
+                       SHA:           "103ff9234cefeee5ec5361d22b49fbb04d385885",
+                       LastCommitSHA: lastCommitSHA,
+                       Type:          "file",
+                       Size:          18,
+                       Encoding:      &encoding,
+                       Content:       &content,
+                       URL:           &selfURL,
+                       HTMLURL:       &htmlURL,
+                       GitURL:        &gitURL,
+                       DownloadURL:   &downloadURL,
+                       Links: &api.FileLinksResponse{
+                               Self:    &selfURL,
+                               GitURL:  &gitURL,
+                               HTMLURL: &htmlURL,
+                       },
+               },
+               Commit: &api.FileCommitResponse{
+                       CommitMeta: api.CommitMeta{
+                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
+                               SHA: commitID,
+                       },
+                       HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
+                       Author: &api.CommitUser{
+                               Identity: api.Identity{
+                                       Name:  "User Two",
+                                       Email: "user2@noreply.example.org",
+                               },
+                               Date: time.Now().UTC().Format(time.RFC3339),
+                       },
+                       Committer: &api.CommitUser{
+                               Identity: api.Identity{
+                                       Name:  "User Two",
+                                       Email: "user2@noreply.example.org",
+                               },
+                               Date: time.Now().UTC().Format(time.RFC3339),
+                       },
+                       Parents: []*api.CommitMeta{
+                               {
+                                       URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
+                                       SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+                               },
+                       },
+                       Message: "Updates README.md\n",
+                       Tree: &api.CommitMeta{
+                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+                               SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
+                       },
+               },
+               Verification: &api.PayloadCommitVerification{
+                       Verified:  false,
+                       Reason:    "gpg.error.not_signed_commit",
+                       Signature: "",
+                       Payload:   "",
+               },
+       }
+}
+
+func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA string) *api.FileResponse {
+       encoding := "base64"
+       content := "VGhpcyBpcyBVUERBVEVEIGNvbnRlbnQgZm9yIHRoZSBSRUFETUUgZmlsZQ=="
+       selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + filename + "?ref=master"
+       htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + filename
+       gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647"
+       downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + filename
+       return &api.FileResponse{
+               Content: &api.ContentsResponse{
+                       Name:          filename,
+                       Path:          filename,
+                       SHA:           "dbf8d00e022e05b7e5cf7e535de857de57925647",
+                       LastCommitSHA: lastCommitSHA,
+                       Type:          "file",
+                       Size:          43,
+                       Encoding:      &encoding,
+                       Content:       &content,
+                       URL:           &selfURL,
+                       HTMLURL:       &htmlURL,
+                       GitURL:        &gitURL,
+                       DownloadURL:   &downloadURL,
+                       Links: &api.FileLinksResponse{
+                               Self:    &selfURL,
+                               GitURL:  &gitURL,
+                               HTMLURL: &htmlURL,
+                       },
+               },
+               Commit: &api.FileCommitResponse{
+                       CommitMeta: api.CommitMeta{
+                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
+                               SHA: commitID,
+                       },
+                       HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
+                       Author: &api.CommitUser{
+                               Identity: api.Identity{
+                                       Name:  "User Two",
+                                       Email: "user2@noreply.example.org",
+                               },
+                               Date: time.Now().UTC().Format(time.RFC3339),
+                       },
+                       Committer: &api.CommitUser{
+                               Identity: api.Identity{
+                                       Name:  "User Two",
+                                       Email: "user2@noreply.example.org",
+                               },
+                               Date: time.Now().UTC().Format(time.RFC3339),
+                       },
+                       Parents: []*api.CommitMeta{
+                               {
+                                       URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
+                                       SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+                               },
+                       },
+                       Message: "Updates README.md\n",
+                       Tree: &api.CommitMeta{
+                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+                               SHA: "f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+                       },
+               },
+               Verification: &api.PayloadCommitVerification{
+                       Verified:  false,
+                       Reason:    "gpg.error.not_signed_commit",
+                       Signature: "",
+                       Payload:   "",
+               },
+       }
+}
+
+func TestChangeRepoFilesForCreate(t *testing.T) {
+       // setup
+       onGiteaRun(t, func(t *testing.T, u *url.URL) {
+               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 := getCreateRepoFilesOptions(repo)
+
+               // test
+               filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+               // asserts
+               assert.NoError(t, err)
+               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
+               defer gitRepo.Close()
+
+               commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch)
+               lastCommit, _ := gitRepo.GetCommitByPath("new/file.txt")
+               expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String())
+               assert.NotNil(t, expectedFileResponse)
+               if expectedFileResponse != nil {
+                       assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+                       assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+                       assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+                       assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
+                       assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
+               }
+       })
+}
+
+func TestChangeRepoFilesForUpdate(t *testing.T) {
+       // setup
+       onGiteaRun(t, func(t *testing.T, u *url.URL) {
+               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 := getUpdateRepoFilesOptions(repo)
+
+               // test
+               filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+               // asserts
+               assert.NoError(t, err)
+               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
+               defer gitRepo.Close()
+
+               commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
+               lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+               expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
+               assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+               assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+               assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+               assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
+               assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
+       })
+}
+
+func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) {
+       // setup
+       onGiteaRun(t, func(t *testing.T, u *url.URL) {
+               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 := getUpdateRepoFilesOptions(repo)
+               opts.Files[0].FromTreePath = "README.md"
+               opts.Files[0].TreePath = "README_new.md" // new file name, README_new.md
+
+               // test
+               filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+               // asserts
+               assert.NoError(t, err)
+               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
+               defer gitRepo.Close()
+
+               commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
+               lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+               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
+               fromEntry, err := commit.GetTreeEntryByPath(opts.Files[0].FromTreePath)
+               switch err.(type) {
+               case git.ErrNotExist:
+                       // correct, continue
+               default:
+                       t.Fatalf("expected git.ErrNotExist, got:%v", err)
+               }
+               toEntry, err := commit.GetTreeEntryByPath(opts.Files[0].TreePath)
+               assert.NoError(t, err)
+               assert.Nil(t, fromEntry)  // Should no longer exist here
+               assert.NotNil(t, toEntry) // Should exist here
+               // assert SHA has remained the same but paths use the new file name
+               assert.EqualValues(t, expectedFileResponse.Content.SHA, filesResponse.Files[0].SHA)
+               assert.EqualValues(t, expectedFileResponse.Content.Name, filesResponse.Files[0].Name)
+               assert.EqualValues(t, expectedFileResponse.Content.Path, filesResponse.Files[0].Path)
+               assert.EqualValues(t, expectedFileResponse.Content.URL, filesResponse.Files[0].URL)
+               assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+               assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+       })
+}
+
+// Test opts with branch names removed, should get same results as above test
+func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
+       // setup
+       onGiteaRun(t, func(t *testing.T, u *url.URL) {
+               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 := getUpdateRepoFilesOptions(repo)
+               opts.OldBranch = ""
+               opts.NewBranch = ""
+
+               // test
+               filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+               // asserts
+               assert.NoError(t, err)
+               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
+               defer gitRepo.Close()
+
+               commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch)
+               lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+               expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
+               assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+       })
+}
+
+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
+       onGiteaRun(t, func(t *testing.T, u *url.URL) {
+               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 := getUpdateRepoFilesOptions(repo)
+                       opts.OldBranch = "bad_branch"
+                       filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Error(t, err)
+                       assert.Nil(t, filesResponse)
+                       expectedError := "branch does not exist [name: " + opts.OldBranch + "]"
+                       assert.EqualError(t, err, expectedError)
+               })
+
+               t.Run("bad SHA", func(t *testing.T) {
+                       opts := getUpdateRepoFilesOptions(repo)
+                       origSHA := opts.Files[0].SHA
+                       opts.Files[0].SHA = "bad_sha"
+                       filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Nil(t, filesResponse)
+                       assert.Error(t, err)
+                       expectedError := "sha does not match [given: " + opts.Files[0].SHA + ", expected: " + origSHA + "]"
+                       assert.EqualError(t, err, expectedError)
+               })
+
+               t.Run("new branch already exists", func(t *testing.T) {
+                       opts := getUpdateRepoFilesOptions(repo)
+                       opts.NewBranch = "develop"
+                       filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Nil(t, filesResponse)
+                       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 := getUpdateRepoFilesOptions(repo)
+                       opts.Files[0].TreePath = ""
+                       filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Nil(t, filesResponse)
+                       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 := getUpdateRepoFilesOptions(repo)
+                       opts.Files[0].TreePath = ".git"
+                       filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Nil(t, filesResponse)
+                       assert.Error(t, err)
+                       expectedError := "path contains a malformed path component [path: " + opts.Files[0].TreePath + "]"
+                       assert.EqualError(t, err, expectedError)
+               })
+
+               t.Run("create file that already exists", func(t *testing.T) {
+                       opts := getCreateRepoFilesOptions(repo)
+                       opts.Files[0].TreePath = "README.md" // already exists
+                       fileResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+                       assert.Nil(t, fileResponse)
+                       assert.Error(t, err)
+                       expectedError := "repository file already exists [path: " + opts.Files[0].TreePath + "]"
+                       assert.EqualError(t, err, expectedError)
+               })
+       })
+}
diff --git a/tests/integration/repofiles_delete_test.go b/tests/integration/repofiles_delete_test.go
deleted file mode 100644 (file)
index 6698b28..0000000
+++ /dev/null
@@ -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)
-       })
-}
diff --git a/tests/integration/repofiles_update_test.go b/tests/integration/repofiles_update_test.go
deleted file mode 100644 (file)
index 47b61c1..0000000
+++ /dev/null
@@ -1,415 +0,0 @@
-// Copyright 2019 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package integration
-
-import (
-       "net/url"
-       "path/filepath"
-       "testing"
-       "time"
-
-       repo_model "code.gitea.io/gitea/models/repo"
-       "code.gitea.io/gitea/modules/git"
-       "code.gitea.io/gitea/modules/setting"
-       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 getCreateRepoFileOptions(repo *repo_model.Repository) *files_service.UpdateRepoFileOptions {
-       return &files_service.UpdateRepoFileOptions{
-               OldBranch: repo.DefaultBranch,
-               NewBranch: repo.DefaultBranch,
-               TreePath:  "new/file.txt",
-               Message:   "Creates new/file.txt",
-               Content:   "This is a NEW file",
-               IsNewFile: true,
-               Author:    nil,
-               Committer: nil,
-       }
-}
-
-func getUpdateRepoFileOptions(repo *repo_model.Repository) *files_service.UpdateRepoFileOptions {
-       return &files_service.UpdateRepoFileOptions{
-               OldBranch: repo.DefaultBranch,
-               NewBranch: repo.DefaultBranch,
-               TreePath:  "README.md",
-               Message:   "Updates README.md",
-               SHA:       "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
-               Content:   "This is UPDATED content for the README file",
-               IsNewFile: false,
-               Author:    nil,
-               Committer: nil,
-       }
-}
-
-func getExpectedFileResponseForRepofilesCreate(commitID, lastCommitSHA string) *api.FileResponse {
-       treePath := "new/file.txt"
-       encoding := "base64"
-       content := "VGhpcyBpcyBhIE5FVyBmaWxl"
-       selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
-       htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath
-       gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885"
-       downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath
-       return &api.FileResponse{
-               Content: &api.ContentsResponse{
-                       Name:          filepath.Base(treePath),
-                       Path:          treePath,
-                       SHA:           "103ff9234cefeee5ec5361d22b49fbb04d385885",
-                       LastCommitSHA: lastCommitSHA,
-                       Type:          "file",
-                       Size:          18,
-                       Encoding:      &encoding,
-                       Content:       &content,
-                       URL:           &selfURL,
-                       HTMLURL:       &htmlURL,
-                       GitURL:        &gitURL,
-                       DownloadURL:   &downloadURL,
-                       Links: &api.FileLinksResponse{
-                               Self:    &selfURL,
-                               GitURL:  &gitURL,
-                               HTMLURL: &htmlURL,
-                       },
-               },
-               Commit: &api.FileCommitResponse{
-                       CommitMeta: api.CommitMeta{
-                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
-                               SHA: commitID,
-                       },
-                       HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
-                       Author: &api.CommitUser{
-                               Identity: api.Identity{
-                                       Name:  "User Two",
-                                       Email: "user2@noreply.example.org",
-                               },
-                               Date: time.Now().UTC().Format(time.RFC3339),
-                       },
-                       Committer: &api.CommitUser{
-                               Identity: api.Identity{
-                                       Name:  "User Two",
-                                       Email: "user2@noreply.example.org",
-                               },
-                               Date: time.Now().UTC().Format(time.RFC3339),
-                       },
-                       Parents: []*api.CommitMeta{
-                               {
-                                       URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
-                                       SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
-                               },
-                       },
-                       Message: "Updates README.md\n",
-                       Tree: &api.CommitMeta{
-                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
-                               SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
-                       },
-               },
-               Verification: &api.PayloadCommitVerification{
-                       Verified:  false,
-                       Reason:    "gpg.error.not_signed_commit",
-                       Signature: "",
-                       Payload:   "",
-               },
-       }
-}
-
-func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA string) *api.FileResponse {
-       encoding := "base64"
-       content := "VGhpcyBpcyBVUERBVEVEIGNvbnRlbnQgZm9yIHRoZSBSRUFETUUgZmlsZQ=="
-       selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + filename + "?ref=master"
-       htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + filename
-       gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647"
-       downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + filename
-       return &api.FileResponse{
-               Content: &api.ContentsResponse{
-                       Name:          filename,
-                       Path:          filename,
-                       SHA:           "dbf8d00e022e05b7e5cf7e535de857de57925647",
-                       LastCommitSHA: lastCommitSHA,
-                       Type:          "file",
-                       Size:          43,
-                       Encoding:      &encoding,
-                       Content:       &content,
-                       URL:           &selfURL,
-                       HTMLURL:       &htmlURL,
-                       GitURL:        &gitURL,
-                       DownloadURL:   &downloadURL,
-                       Links: &api.FileLinksResponse{
-                               Self:    &selfURL,
-                               GitURL:  &gitURL,
-                               HTMLURL: &htmlURL,
-                       },
-               },
-               Commit: &api.FileCommitResponse{
-                       CommitMeta: api.CommitMeta{
-                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
-                               SHA: commitID,
-                       },
-                       HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
-                       Author: &api.CommitUser{
-                               Identity: api.Identity{
-                                       Name:  "User Two",
-                                       Email: "user2@noreply.example.org",
-                               },
-                               Date: time.Now().UTC().Format(time.RFC3339),
-                       },
-                       Committer: &api.CommitUser{
-                               Identity: api.Identity{
-                                       Name:  "User Two",
-                                       Email: "user2@noreply.example.org",
-                               },
-                               Date: time.Now().UTC().Format(time.RFC3339),
-                       },
-                       Parents: []*api.CommitMeta{
-                               {
-                                       URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
-                                       SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
-                               },
-                       },
-                       Message: "Updates README.md\n",
-                       Tree: &api.CommitMeta{
-                               URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
-                               SHA: "f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
-                       },
-               },
-               Verification: &api.PayloadCommitVerification{
-                       Verified:  false,
-                       Reason:    "gpg.error.not_signed_commit",
-                       Signature: "",
-                       Payload:   "",
-               },
-       }
-}
-
-func TestCreateOrUpdateRepoFileForCreate(t *testing.T) {
-       // setup
-       onGiteaRun(t, func(t *testing.T, u *url.URL) {
-               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 := getCreateRepoFileOptions(repo)
-
-               // test
-               fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts)
-
-               // asserts
-               assert.NoError(t, err)
-               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
-               defer gitRepo.Close()
-
-               commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch)
-               lastCommit, _ := gitRepo.GetCommitByPath("new/file.txt")
-               expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String())
-               assert.NotNil(t, expectedFileResponse)
-               if expectedFileResponse != nil {
-                       assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
-                       assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
-                       assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
-                       assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email)
-                       assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name)
-               }
-       })
-}
-
-func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) {
-       // setup
-       onGiteaRun(t, func(t *testing.T, u *url.URL) {
-               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 := getUpdateRepoFileOptions(repo)
-
-               // test
-               fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts)
-
-               // asserts
-               assert.NoError(t, err)
-               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
-               defer gitRepo.Close()
-
-               commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
-               lastCommit, _ := commit.GetCommitByPath(opts.TreePath)
-               expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String())
-               assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
-               assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
-               assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
-               assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email)
-               assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name)
-       })
-}
-
-func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) {
-       // setup
-       onGiteaRun(t, func(t *testing.T, u *url.URL) {
-               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 := getUpdateRepoFileOptions(repo)
-               opts.FromTreePath = "README.md"
-               opts.TreePath = "README_new.md" // new file name, README_new.md
-
-               // test
-               fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts)
-
-               // asserts
-               assert.NoError(t, err)
-               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
-               defer gitRepo.Close()
-
-               commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
-               lastCommit, _ := commit.GetCommitByPath(opts.TreePath)
-               expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String())
-               // assert that the old file no longer exists in the last commit of the branch
-               fromEntry, err := commit.GetTreeEntryByPath(opts.FromTreePath)
-               switch err.(type) {
-               case git.ErrNotExist:
-                       // correct, continue
-               default:
-                       t.Fatalf("expected git.ErrNotExist, got:%v", err)
-               }
-               toEntry, err := commit.GetTreeEntryByPath(opts.TreePath)
-               assert.NoError(t, err)
-               assert.Nil(t, fromEntry)  // Should no longer exist here
-               assert.NotNil(t, toEntry) // Should exist here
-               // 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.Name, fileResponse.Content.Name)
-               assert.EqualValues(t, expectedFileResponse.Content.Path, fileResponse.Content.Path)
-               assert.EqualValues(t, expectedFileResponse.Content.URL, fileResponse.Content.URL)
-               assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
-               assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
-       })
-}
-
-// Test opts with branch names removed, should get same results as above test
-func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) {
-       // setup
-       onGiteaRun(t, func(t *testing.T, u *url.URL) {
-               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 := getUpdateRepoFileOptions(repo)
-               opts.OldBranch = ""
-               opts.NewBranch = ""
-
-               // test
-               fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts)
-
-               // asserts
-               assert.NoError(t, err)
-               gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath())
-               defer gitRepo.Close()
-
-               commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch)
-               lastCommit, _ := commit.GetCommitByPath(opts.TreePath)
-               expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String())
-               assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
-       })
-}
-
-func TestCreateOrUpdateRepoFileErrors(t *testing.T) {
-       // setup
-       onGiteaRun(t, func(t *testing.T, u *url.URL) {
-               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 := getUpdateRepoFileOptions(repo)
-                       opts.OldBranch = "bad_branch"
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(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 := getUpdateRepoFileOptions(repo)
-                       origSHA := opts.SHA
-                       opts.SHA = "bad_sha"
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(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 := getUpdateRepoFileOptions(repo)
-                       opts.NewBranch = "develop"
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(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 := getUpdateRepoFileOptions(repo)
-                       opts.TreePath = ""
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(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 := getUpdateRepoFileOptions(repo)
-                       opts.TreePath = ".git"
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(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)
-               })
-
-               t.Run("create file that already exists", func(t *testing.T) {
-                       opts := getCreateRepoFileOptions(repo)
-                       opts.TreePath = "README.md" // already exists
-                       fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts)
-                       assert.Nil(t, fileResponse)
-                       assert.Error(t, err)
-                       expectedError := "repository file already exists [path: " + opts.TreePath + "]"
-                       assert.EqualError(t, err, expectedError)
-               })
-       })
-}