From 275d4b7e3f4595206e5c4b1657d4f6d6969d9ce2 Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Mon, 29 May 2023 11:41:35 +0200 Subject: [PATCH] API endpoint for changing/creating/deleting multiple files (#24887) This PR creates an API endpoint for creating/updating/deleting multiple files in one API call similar to the solution provided by [GitLab](https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions). To archive this, the CreateOrUpdateRepoFile and DeleteRepoFIle functions in files service are unified into one function supporting multiple files and actions. Resolves #14619 --- modules/structs/repo_file.go | 36 ++ routers/api/v1/api.go | 1 + routers/api/v1/repo/file.go | 195 +++++- routers/api/v1/swagger/options.go | 3 + routers/api/v1/swagger/repo.go | 7 + routers/web/repo/editor.go | 36 +- services/repository/files/delete.go | 204 ------- services/repository/files/file.go | 30 + services/repository/files/update.go | 555 ++++++++++-------- templates/swagger/v1_json.tmpl | 161 +++++ tests/integration/api_repo_file_helpers.go | 18 +- .../integration/api_repo_files_change_test.go | 309 ++++++++++ tests/integration/pull_merge_test.go | 24 +- tests/integration/pull_update_test.go | 24 +- ...pdate_test.go => repofiles_change_test.go} | 283 ++++++--- tests/integration/repofiles_delete_test.go | 201 ------- 16 files changed, 1309 insertions(+), 778 deletions(-) delete mode 100644 services/repository/files/delete.go create mode 100644 tests/integration/api_repo_files_change_test.go rename tests/integration/{repofiles_update_test.go => repofiles_change_test.go} (54%) delete mode 100644 tests/integration/repofiles_delete_test.go diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 328d7e47c..6ca0e1c10 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index fccfc5792..45e36e84f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 786407827..ae0d31c2a 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -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 } } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 09bb1d18f..353d32e21 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -116,6 +116,9 @@ type swaggerParameterBodies struct { // in:body EditAttachmentOptions api.EditAttachmentOptions + // in:body + ChangeFilesOptions api.ChangeFilesOptions + // in:body CreateFileOptions api.CreateFileOptions diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 10056ac8c..3e23aa4d5 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -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 { diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index b94aa1b7b..7433a0a56 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -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 index faa60bb3b..000000000 --- a/services/repository/files/delete.go +++ /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 -} diff --git a/services/repository/files/file.go b/services/repository/files/file.go index dc1e547dc..16783f5b5 100644 --- a/services/repository/files/file.go +++ b/services/repository/files/file.go @@ -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 { diff --git a/services/repository/files/update.go b/services/repository/files/update.go index 25014f441..81d5e3277 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -41,23 +41,36 @@ type CommitDateOptions struct { Committer time.Time } -// UpdateRepoFileOptions holds the repository file update options -type UpdateRepoFileOptions struct { +type ChangeRepoFile struct { + Operation string + TreePath string + FromTreePath string + Content string + SHA string + Options *RepoFileOptions +} + +// UpdateRepoFilesOptions holds the repository files update options +type ChangeRepoFilesOptions struct { LastCommitID string OldBranch string NewBranch string - TreePath string - FromTreePath string Message string - Content string - SHA string - IsNewFile bool + 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,176 +282,27 @@ 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(), - } - } - } else if opts.LastCommitID != "" { - // If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw - // an error, but only if we aren't creating a new branch. - if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { - if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil { - return nil, err - } else if changed { - return nil, models.ErrCommitIDDoesNotMatch{ - GivenCommitID: opts.LastCommitID, - CurrentCommitID: opts.LastCommitID, - } - } - // The file wasn't modified, so we are good to delete it - } - } else { - // When updating a file, a lastCommitID or SHA needs to be given to make sure other commits - // haven't been made. We throw an error if one wasn't provided. - return nil, models.ErrSHAOrCommitIDNotProvided{} - } - encoding, bom = detectEncodingAndBOM(fromEntry, repo) - executable = fromEntry.IsExecutable() } + } - // For the path where this file will be created/updated, we need to make - // sure no parts of the path are existing files or links except for the last - // item in the path which is the file name, and that shouldn't exist IF it is - // a new file OR is being moved to a new path. - treePathParts := strings.Split(treePath, "/") - subTreePath := "" - for index, part := range treePathParts { - subTreePath = path.Join(subTreePath, part) - entry, err := commit.GetTreeEntryByPath(subTreePath) - if err != nil { - if git.IsErrNotExist(err) { - // Means there is no item with that name, so we're good - break - } + 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 } - if index < len(treePathParts)-1 { - if !entry.IsDir() { - return nil, models.ErrFilePathInvalid{ - Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath), - Path: subTreePath, - Name: part, - Type: git.EntryModeBlob, - } - } - } else if entry.IsLink() { - return nil, 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{ - 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 { - // The entry shouldn't exist if we are creating new file or moving to a new path - return nil, models.ErrRepoFileAlreadyExists{ - Path: treePath, - } - } - - } - } - - // Get the two paths (might be the same if not moving) from the index if they exist - filesInIndex, err := t.LsFiles(opts.TreePath, opts.FromTreePath) - if err != nil { - return nil, fmt.Errorf("UpdateRepoFile: %w", err) - } - // If is a new file (not updating) then the given path shouldn't exist - if opts.IsNewFile { - for _, file := range filesInIndex { - if file == opts.TreePath { - return nil, models.ErrRepoFileAlreadyExists{ - Path: opts.TreePath, - } - } - } - } - - // Remove the old path from the tree - if fromTreePath != treePath && len(filesInIndex) > 0 { - for _, file := range filesInIndex { - if file == fromTreePath { - if err := t.RemoveFilesFromIndex(opts.FromTreePath); err != nil { - return nil, err - } - } - } - } - - content := opts.Content - if bom { - content = string(charset.UTF8BOM) + content - } - if encoding != "UTF-8" { - charsetEncoding, _ := stdcharset.Lookup(encoding) - if charsetEncoding != nil { - result, _, err := transform.String(charsetEncoding.NewEncoder(), content) - if err != nil { - // Look if we can't encode back in to the original we should just stick with utf-8 - log.Error("Error re-encoding %s (%s) as %s - will stay as UTF-8: %v", opts.TreePath, opts.FromTreePath, encoding, err) - result = content - } - content = result - } else { - log.Error("Unknown encoding: %s", encoding) - } - } - // Reset the opts.Content to our adjusted content to ensure that LFS gets the correct content - opts.Content = content - var lfsMetaObject *git_model.LFSMetaObject - - if setting.LFS.StartServer && hasOldBranch { - // Check there is no way this can return multiple infos - filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{ - Attributes: []string{"filter"}, - Filenames: []string{treePath}, - CachedOnly: true, - }) - if err != nil { - return nil, err - } - - if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" { - // OK so we are supposed to LFS this data! - pointer, err := lfs.GeneratePointer(strings.NewReader(opts.Content)) - if err != nil { + case "delete": + // Remove the file from the index + if err := t.RemoveFilesFromIndex(file.TreePath); err != nil { return nil, err } - lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repo.ID} - content = pointer.StringContent() - } - } - // Add the object to the database - objectHash, err := t.HashObject(strings.NewReader(content)) - if err != nil { - return nil, err - } - - // Add the object to the index - if executable { - if err := t.AddObjectToIndex("100755", objectHash, treePath); err != nil { - return nil, err - } - } else { - if err := t.AddObjectToIndex("100644", objectHash, treePath); err != nil { - return nil, err + default: + return nil, fmt.Errorf("Invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath) } } @@ -422,27 +323,6 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do return nil, err } - if lfsMetaObject != nil { - // We have an LFS object - create it - lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject) - if err != nil { - return nil, err - } - contentStore := lfs.NewContentStore() - exist, err := contentStore.Exists(lfsMetaObject.Pointer) - if err != nil { - return nil, err - } - if !exist { - if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(opts.Content)); err != nil { - if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repo.ID, lfsMetaObject.Oid); err2 != nil { - return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err) - } - return nil, err - } - } - } - // Then push this tree to NewBranch if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { log.Error("%T %v", err, err) @@ -454,7 +334,7 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do return nil, err } - file, err := GetFileResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePath) + filesReponse, err := GetFilesResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePaths) if err != nil { return nil, err } @@ -463,25 +343,238 @@ func CreateOrUpdateRepoFile(ctx context.Context, repo *repo_model.Repository, do _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false}, "is_empty") } - return file, nil + return filesReponse, nil +} + +// handles the check for various issues for ChangeRepoFiles +func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions, repo *repo_model.Repository) error { + if file.Operation == "update" || file.Operation == "delete" { + fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath) + if err != nil { + return err + } + if file.SHA != "" { + // If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error + if file.SHA != fromEntry.ID.String() { + return models.ErrSHADoesNotMatch{ + Path: file.Options.treePath, + GivenSHA: file.SHA, + CurrentSHA: fromEntry.ID.String(), + } + } + } else if opts.LastCommitID != "" { + // If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw + // an error, but only if we aren't creating a new branch. + if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { + if changed, err := commit.FileChangedSinceCommit(file.Options.treePath, opts.LastCommitID); err != nil { + return err + } else if changed { + return models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + // The file wasn't modified, so we are good to delete it + } + } else { + // When updating a file, a lastCommitID or SHA needs to be given to make sure other commits + // haven't been made. We throw an error if one wasn't provided. + return models.ErrSHAOrCommitIDNotProvided{} + } + file.Options.encoding, file.Options.bom = detectEncodingAndBOM(fromEntry, repo) + file.Options.executable = fromEntry.IsExecutable() + } + if file.Operation == "create" || file.Operation == "update" { + // For the path where this file will be created/updated, we need to make + // sure no parts of the path are existing files or links except for the last + // item in the path which is the file name, and that shouldn't exist IF it is + // a new file OR is being moved to a new path. + treePathParts := strings.Split(file.Options.treePath, "/") + subTreePath := "" + for index, part := range treePathParts { + subTreePath = path.Join(subTreePath, part) + entry, err := commit.GetTreeEntryByPath(subTreePath) + if err != nil { + if git.IsErrNotExist(err) { + // Means there is no item with that name, so we're good + break + } + return err + } + if index < len(treePathParts)-1 { + if !entry.IsDir() { + return models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeBlob, + } + } + } else if entry.IsLink() { + return models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeSymlink, + } + } else if entry.IsDir() { + return models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeTree, + } + } else if file.Options.fromTreePath != file.Options.treePath || file.Operation == "create" { + // The entry shouldn't exist if we are creating new file or moving to a new path + return models.ErrRepoFileAlreadyExists{ + Path: file.Options.treePath, + } + } + + } + } + + return nil +} + +// handle creating or updating a file for ChangeRepoFiles +func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64, hasOldBranch bool) error { + // Get the two paths (might be the same if not moving) from the index if they exist + filesInIndex, err := t.LsFiles(file.TreePath, file.FromTreePath) + if err != nil { + return fmt.Errorf("UpdateRepoFile: %w", err) + } + // If is a new file (not updating) then the given path shouldn't exist + if file.Operation == "create" { + for _, indexFile := range filesInIndex { + if indexFile == file.TreePath { + return models.ErrRepoFileAlreadyExists{ + Path: file.TreePath, + } + } + } + } + + // Remove the old path from the tree + if file.Options.fromTreePath != file.Options.treePath && len(filesInIndex) > 0 { + for _, indexFile := range filesInIndex { + if indexFile == file.Options.fromTreePath { + if err := t.RemoveFilesFromIndex(file.FromTreePath); err != nil { + return err + } + } + } + } + + content := file.Content + if file.Options.bom { + content = string(charset.UTF8BOM) + content + } + if file.Options.encoding != "UTF-8" { + charsetEncoding, _ := stdcharset.Lookup(file.Options.encoding) + if charsetEncoding != nil { + result, _, err := transform.String(charsetEncoding.NewEncoder(), content) + if err != nil { + // Look if we can't encode back in to the original we should just stick with utf-8 + log.Error("Error re-encoding %s (%s) as %s - will stay as UTF-8: %v", file.TreePath, file.FromTreePath, file.Options.encoding, err) + result = content + } + content = result + } else { + log.Error("Unknown encoding: %s", file.Options.encoding) + } + } + // Reset the opts.Content to our adjusted content to ensure that LFS gets the correct content + file.Content = content + var lfsMetaObject *git_model.LFSMetaObject + + if setting.LFS.StartServer && hasOldBranch { + // Check there is no way this can return multiple infos + filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{ + Attributes: []string{"filter"}, + Filenames: []string{file.Options.treePath}, + CachedOnly: true, + }) + if err != nil { + return err + } + + if filename2attribute2info[file.Options.treePath] != nil && filename2attribute2info[file.Options.treePath]["filter"] == "lfs" { + // OK so we are supposed to LFS this data! + pointer, err := lfs.GeneratePointer(strings.NewReader(file.Content)) + if err != nil { + return err + } + lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID} + content = pointer.StringContent() + } + } + + // Add the object to the database + objectHash, err := t.HashObject(strings.NewReader(content)) + if err != nil { + return err + } + + // Add the object to the index + if file.Options.executable { + if err := t.AddObjectToIndex("100755", objectHash, file.Options.treePath); err != nil { + return err + } + } else { + if err := t.AddObjectToIndex("100644", objectHash, file.Options.treePath); err != nil { + return err + } + } + + if lfsMetaObject != nil { + // We have an LFS object - create it + lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject) + if err != nil { + return err + } + exist, err := contentStore.Exists(lfsMetaObject.Pointer) + if err != nil { + return err + } + if !exist { + if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(file.Content)); err != nil { + if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil { + return fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err) + } + return err + } + } + } + + return nil } // VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch -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 } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 15043e465..75492ab63 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4063,6 +4063,57 @@ "$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}": { @@ -15891,6 +15942,90 @@ }, "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", @@ -18326,6 +18461,26 @@ }, "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", @@ -21996,6 +22151,12 @@ "$ref": "#/definitions/FileResponse" } }, + "FilesResponse": { + "description": "FilesResponse", + "schema": { + "$ref": "#/definitions/FilesResponse" + } + }, "GPGKey": { "description": "GPGKey", "schema": { diff --git a/tests/integration/api_repo_file_helpers.go b/tests/integration/api_repo_file_helpers.go index d773bcd62..8e9b2bfec 100644 --- a/tests/integration/api_repo_file_helpers.go +++ b/tests/integration/api_repo_file_helpers.go @@ -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 index 000000000..38187ec5b --- /dev/null +++ b/tests/integration/api_repo_files_change_test.go @@ -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) + }) +} diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 9271f25e5..f6a36f60a 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -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", }) diff --git a/tests/integration/pull_update_test.go b/tests/integration/pull_update_test.go index 1b6665651..b94731002 100644 --- a/tests/integration/pull_update_test.go +++ b/tests/integration/pull_update_test.go @@ -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_update_test.go b/tests/integration/repofiles_change_test.go similarity index 54% rename from tests/integration/repofiles_update_test.go rename to tests/integration/repofiles_change_test.go index 47b61c1ee..a257b95a8 100644 --- a/tests/integration/repofiles_update_test.go +++ b/tests/integration/repofiles_change_test.go @@ -10,6 +10,7 @@ import ( "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" @@ -19,33 +20,90 @@ import ( "github.com/stretchr/testify/assert" ) -func getCreateRepoFileOptions(repo *repo_model.Repository) *files_service.UpdateRepoFileOptions { - return &files_service.UpdateRepoFileOptions{ +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, - 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{ +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, - 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 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" @@ -183,7 +241,7 @@ func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA } } -func TestCreateOrUpdateRepoFileForCreate(t *testing.T) { +func TestChangeRepoFilesForCreate(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { ctx := test.MockContext(t, "user2/repo1") @@ -196,10 +254,10 @@ func TestCreateOrUpdateRepoFileForCreate(t *testing.T) { repo := ctx.Repo.Repository doer := ctx.Doer - opts := getCreateRepoFileOptions(repo) + opts := getCreateRepoFilesOptions(repo) // test - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) // asserts assert.NoError(t, err) @@ -211,16 +269,16 @@ func TestCreateOrUpdateRepoFileForCreate(t *testing.T) { 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) + 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 TestCreateOrUpdateRepoFileForUpdate(t *testing.T) { +func TestChangeRepoFilesForUpdate(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { ctx := test.MockContext(t, "user2/repo1") @@ -233,10 +291,10 @@ func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) { repo := ctx.Repo.Repository doer := ctx.Doer - opts := getUpdateRepoFileOptions(repo) + opts := getUpdateRepoFilesOptions(repo) // test - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) // asserts assert.NoError(t, err) @@ -244,17 +302,17 @@ func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) { 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) + 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 TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) { +func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { ctx := test.MockContext(t, "user2/repo1") @@ -267,12 +325,12 @@ func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) { 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 + opts := getUpdateRepoFilesOptions(repo) + opts.Files[0].FromTreePath = "README.md" + opts.Files[0].TreePath = "README_new.md" // new file name, README_new.md // test - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) // asserts assert.NoError(t, err) @@ -280,32 +338,32 @@ func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) { defer gitRepo.Close() commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) - lastCommit, _ := commit.GetCommitByPath(opts.TreePath) - expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.TreePath, lastCommit.ID.String()) + 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.FromTreePath) + 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.TreePath) + 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, 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) + 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 TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) { +func TestChangeRepoFilesWithoutBranchNames(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { ctx := test.MockContext(t, "user2/repo1") @@ -318,12 +376,12 @@ func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) { repo := ctx.Repo.Repository doer := ctx.Doer - opts := getUpdateRepoFileOptions(repo) + opts := getUpdateRepoFilesOptions(repo) opts.OldBranch = "" opts.NewBranch = "" // test - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) // asserts assert.NoError(t, err) @@ -331,13 +389,86 @@ func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) { 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) + 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 TestCreateOrUpdateRepoFileErrors(t *testing.T) { +func TestChangeRepoFilesForDelete(t *testing.T) { + onGiteaRun(t, testDeleteRepoFiles) +} + +func testDeleteRepoFiles(t *testing.T, u *url.URL) { + // setup + unittest.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + repo := ctx.Repo.Repository + doer := ctx.Doer + opts := getDeleteRepoFilesOptions(repo) + + t.Run("Delete README.md file", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.NoError(t, err) + expectedFileResponse := getExpectedFileResponseForRepofilesDelete(u) + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) + assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification) + }) + + t.Run("Verify README.md has been deleted", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]" + assert.EqualError(t, err, expectedError) + }) +} + +// Test opts with branch names removed, same results +func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) { + onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames) +} + +func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) { + // setup + unittest.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + repo := ctx.Repo.Repository + doer := ctx.Doer + opts := getDeleteRepoFilesOptions(repo) + opts.OldBranch = "" + opts.NewBranch = "" + + t.Run("Delete README.md without Branch Name", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.NoError(t, err) + expectedFileResponse := getExpectedFileResponseForRepofilesDelete(u) + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) + assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification) + }) +} + +func TestChangeRepoFilesErrors(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { ctx := test.MockContext(t, "user2/repo1") @@ -352,63 +483,63 @@ func TestCreateOrUpdateRepoFileErrors(t *testing.T) { doer := ctx.Doer t.Run("bad branch", func(t *testing.T) { - opts := getUpdateRepoFileOptions(repo) + opts := getUpdateRepoFilesOptions(repo) opts.OldBranch = "bad_branch" - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) assert.Error(t, err) - assert.Nil(t, fileResponse) + 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 := getUpdateRepoFileOptions(repo) - origSHA := opts.SHA - opts.SHA = "bad_sha" - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) - assert.Nil(t, fileResponse) + 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.SHA + ", expected: " + origSHA + "]" + 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 := getUpdateRepoFileOptions(repo) + opts := getUpdateRepoFilesOptions(repo) opts.NewBranch = "develop" - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) - assert.Nil(t, fileResponse) + 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 := getUpdateRepoFileOptions(repo) - opts.TreePath = "" - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) - assert.Nil(t, fileResponse) + 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 := getUpdateRepoFileOptions(repo) - opts.TreePath = ".git" - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) - assert.Nil(t, fileResponse) + 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.TreePath + "]" + 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 := getCreateRepoFileOptions(repo) - opts.TreePath = "README.md" // already exists - fileResponse, err := files_service.CreateOrUpdateRepoFile(git.DefaultContext, repo, doer, opts) + 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.TreePath + "]" + 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 index 6698b280b..000000000 --- a/tests/integration/repofiles_delete_test.go +++ /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) - }) -}