diff --git a/modules/git/repo.go b/modules/git/repo.go index 29e70d94c86f6..f9ae18158197b 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -191,18 +191,21 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { // PushOptions options when push to remote type PushOptions struct { - Remote string - Branch string - Force bool - Mirror bool - Env []string - Timeout time.Duration + Remote string + Branch string + Force bool + ForceWithLease string + Mirror bool + Env []string + Timeout time.Duration } // Push pushs local commits to given remote branch. func Push(ctx context.Context, repoPath string, opts PushOptions) error { cmd := gitcmd.NewCommand("push") - if opts.Force { + if opts.ForceWithLease != "" { + cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease) + } else if opts.Force { cmd.AddArguments("-f") } if opts.Mirror { diff --git a/modules/structs/repo.go b/modules/structs/repo.go index c1c85837fc89e..47973a5f6a398 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -292,6 +292,21 @@ type RenameBranchRepoOption struct { Name string `json:"name" binding:"Required;GitRefName;MaxSize(100)"` } +// UpdateBranchRepoOption options when updating a branch reference in a repository +// swagger:model +type UpdateBranchRepoOption struct { + // New commit SHA (or any ref) the branch should point to + // + // required: true + NewCommitID string `json:"new_commit_id" binding:"Required"` + + // Expected old commit SHA of the branch; if provided it must match the current tip + OldCommitID string `json:"old_commit_id"` + + // Force update even if the change is not a fast-forward + Force bool `json:"force"` +} + // TransferRepoOption options when transfer a repository's ownership // swagger:model type TransferRepoOption struct { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e6238acce04f2..fc266528fec45 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1239,6 +1239,7 @@ func Routes() *web.Router { m.Get("/*", repo.GetBranch) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch) m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch) + m.Put("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.UpdateBranchRepoOption{}), repo.UpdateBranch) m.Patch("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.RenameBranchRepoOption{}), repo.RenameBranch) }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) m.Group("/branch_protections", func() { diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index b9060e9cbd09f..9f2ca69b8945c 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -380,6 +380,91 @@ func ListBranches(ctx *context.APIContext) { ctx.JSON(http.StatusOK, apiBranches) } +// UpdateBranch moves a branch reference to a new commit. +func UpdateBranch(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/branches/{branch} repository repoUpdateBranch + // --- + // summary: Update a branch reference to a new commit + // 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: branch + // in: path + // description: name of the branch + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateBranchRepoOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" + + opt := web.GetForm(ctx).(*api.UpdateBranchRepoOption) + + branchName := ctx.PathParam("*") + repo := ctx.Repo.Repository + + if repo.IsEmpty { + ctx.APIError(http.StatusNotFound, "Git Repository is empty.") + return + } + + if repo.IsMirror { + ctx.APIError(http.StatusForbidden, "Git Repository is a mirror.") + return + } + + if ctx.Repo.GitRepo == nil { + ctx.APIErrorInternal(nil) + return + } + + if err := repo_service.UpdateBranch(ctx, repo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil { + switch { + case git_model.IsErrBranchNotExist(err): + ctx.APIError(http.StatusNotFound, "Branch doesn't exist.") + case repo_service.IsErrBranchCommitDoesNotMatch(err): + ctx.APIError(http.StatusConflict, err) + case git.IsErrPushOutOfDate(err): + ctx.APIError(http.StatusConflict, "The update is not a fast-forward.") + case git.IsErrPushRejected(err): + rej := err.(*git.ErrPushRejected) + ctx.APIError(http.StatusForbidden, rej.Message) + case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): + ctx.APIError(http.StatusForbidden, err) + case git.IsErrNotExist(err): + ctx.APIError(http.StatusUnprocessableEntity, err) + default: + ctx.APIErrorInternal(err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + // RenameBranch renames a repository's branch. func RenameBranch(ctx *context.APIContext) { // swagger:operation PATCH /repos/{owner}/{repo}/branches/{branch} repository repoRenameBranch diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index b80a9c14ba027..310839374b66c 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -147,6 +147,8 @@ type swaggerParameterBodies struct { // in:body CreateBranchRepoOption api.CreateBranchRepoOption + // in:body + UpdateBranchRepoOption api.UpdateBranchRepoOption // in:body CreateBranchProtectionOption api.CreateBranchProtectionOption diff --git a/services/repository/branch.go b/services/repository/branch.go index 0a2fd30620d22..1e38cc7e9c9d3 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -32,6 +32,7 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" actions_service "code.gitea.io/gitea/services/actions" notify_service "code.gitea.io/gitea/services/notify" + pull_service "code.gitea.io/gitea/services/pull" release_service "code.gitea.io/gitea/services/release" "xorm.io/builder" @@ -483,8 +484,150 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m return "", nil } +// UpdateBranch moves a branch reference to the provided commit. +func UpdateBranch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName, newCommitID, expectedOldCommitID string, force bool) error { + if err := repo.MustNotBeArchived(); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, doer) + if err != nil { + return err + } + if !perm.CanWrite(unit.TypeCode) { + return repo_model.ErrUserDoesNotHaveAccessToRepo{ + UserID: doer.ID, + RepoName: repo.LowerName, + } + } + + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return fmt.Errorf("OpenRepository: %w", err) + } + defer gitRepo.Close() + + branchCommit, err := gitRepo.GetBranchCommit(branchName) + if err != nil { + if git.IsErrNotExist(err) { + return git_model.ErrBranchNotExist{RepoID: repo.ID, BranchName: branchName} + } + return err + } + currentCommitID := branchCommit.ID.String() + + if expectedOldCommitID != "" { + expectedID, err := gitRepo.ConvertToGitID(expectedOldCommitID) + if err != nil { + return fmt.Errorf("ConvertToGitID(old): %w", err) + } + if expectedID.String() != currentCommitID { + return ErrBranchCommitDoesNotMatch{Expected: currentCommitID, Given: expectedID.String()} + } + } + + newID, err := gitRepo.ConvertToGitID(newCommitID) + if err != nil { + return fmt.Errorf("ConvertToGitID(new): %w", err) + } + newCommit, err := gitRepo.GetCommit(newID.String()) + if err != nil { + return err + } + + if newCommit.ID.String() == currentCommitID { + return nil + } + + isForcePush, err := newCommit.IsForcePush(currentCommitID) + if err != nil { + return err + } + if isForcePush && !force { + return &git.ErrPushOutOfDate{Err: errors.New("non fast-forward update requires force"), StdErr: "non-fast-forward", StdOut: ""} + } + + pushEnv := repo_module.PushingEnvironment(doer, repo) + + protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) + if err != nil { + return fmt.Errorf("GetFirstMatchProtectedBranchRule: %w", err) + } + if protectedBranch != nil { + protectedBranch.Repo = repo + globsProtected := protectedBranch.GetProtectedFilePatterns() + if len(globsProtected) > 0 { + changedProtectedFiles, protectErr := pull_service.CheckFileProtection(gitRepo, branchName, currentCommitID, newCommit.ID.String(), globsProtected, 1, pushEnv) + if protectErr != nil { + if !pull_service.IsErrFilePathProtected(protectErr) { + return fmt.Errorf("CheckFileProtection: %w", protectErr) + } + protectedPath := "" + if len(changedProtectedFiles) > 0 { + protectedPath = changedProtectedFiles[0] + } else if pathErr, ok := protectErr.(pull_service.ErrFilePathProtected); ok { + protectedPath = pathErr.Path + } + if protectedPath == "" { + protectedPath = branchName + } + return &git.ErrPushRejected{Message: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedPath)} + } + } + + if isForcePush { + if !protectedBranch.CanUserForcePush(ctx, doer) { + return &git.ErrPushRejected{Message: "Not allowed to force-push to protected branch " + branchName} + } + } else if !protectedBranch.CanUserPush(ctx, doer) { + globsUnprotected := protectedBranch.GetUnprotectedFilePatterns() + if len(globsUnprotected) > 0 { + unprotectedOnly, unprotectedErr := pull_service.CheckUnprotectedFiles(gitRepo, branchName, currentCommitID, newCommit.ID.String(), globsUnprotected, pushEnv) + if unprotectedErr != nil { + return fmt.Errorf("CheckUnprotectedFiles: %w", unprotectedErr) + } + if !unprotectedOnly { + return &git.ErrPushRejected{Message: "Not allowed to push to protected branch " + branchName} + } + } else { + return &git.ErrPushRejected{Message: "Not allowed to push to protected branch " + branchName} + } + } + } + + pushOpts := git.PushOptions{ + Remote: repo.RepoPath(), + Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName), + Env: pushEnv, + } + + if expectedOldCommitID != "" { + pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, currentCommitID) + } + if isForcePush || force { + pushOpts.Force = true + } + return gitrepo.Push(ctx, repo, pushOpts) +} + var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default") +// ErrBranchCommitDoesNotMatch indicates the provided old commit id does not match the branch tip. +type ErrBranchCommitDoesNotMatch struct { + Expected string + Given string +} + +// IsErrBranchCommitDoesNotMatch checks if the error is ErrBranchCommitDoesNotMatch. +func IsErrBranchCommitDoesNotMatch(err error) bool { + _, ok := err.(ErrBranchCommitDoesNotMatch) + return ok +} + +func (e ErrBranchCommitDoesNotMatch) Error() string { + return fmt.Sprintf("branch commit does not match [expected: %s, given: %s]", e.Expected, e.Given) +} + func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error { if branchName == repo.DefaultBranch { return ErrBranchIsDefault diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0cefa6795f4f5..b37937dcee982 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6750,6 +6750,66 @@ } } }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update a branch reference to a new commit", + "operationId": "repoUpdateBranch", + "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 + }, + { + "type": "string", + "description": "name of the branch", + "name": "branch", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateBranchRepoOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, "delete": { "produces": [ "application/json" @@ -28702,6 +28762,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateBranchRepoOption": { + "description": "UpdateBranchRepoOption options when updating a branch reference in a repository", + "type": "object", + "required": [ + "new_commit_id" + ], + "properties": { + "force": { + "description": "Force update even if the change is not a fast-forward", + "type": "boolean", + "x-go-name": "Force" + }, + "new_commit_id": { + "description": "New commit SHA (or any ref) the branch should point to", + "type": "string", + "x-go-name": "NewCommitID" + }, + "old_commit_id": { + "description": "Expected old commit SHA of the branch; if provided it must match the current tip", + "type": "string", + "x-go-name": "OldCommitID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateFileOptions": { "description": "UpdateFileOptions options for updating or creating a file\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", diff --git a/tests/integration/api_branch_test.go b/tests/integration/api_branch_test.go index 2147ef9d0d9c7..821ad2358e37b 100644 --- a/tests/integration/api_branch_test.go +++ b/tests/integration/api_branch_test.go @@ -4,6 +4,8 @@ package integration import ( + "encoding/base64" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -243,6 +245,79 @@ func TestAPIRenameBranch(t *testing.T) { }) } +func TestAPIUpdateBranchReference(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + ctx := NewAPITestContext(t, "user2", "update-branch", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + giteaURL.Path = ctx.GitPath() + + var defaultBranch string + t.Run("CreateRepo", doAPICreateRepository(ctx, false, func(t *testing.T, repo api.Repository) { + defaultBranch = repo.DefaultBranch + })) + + createBranchReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/branches", ctx.Username, ctx.Reponame), &api.CreateBranchRepoOption{ + BranchName: "feature", + OldRefName: defaultBranch, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, createBranchReq, http.StatusCreated) + + var featureInitialCommit string + t.Run("LoadFeatureBranch", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + featureInitialCommit = branch.Commit.ID + assert.NotEmpty(t, featureInitialCommit) + })) + + content := base64.StdEncoding.EncodeToString([]byte("branch update test")) + var newCommit string + doAPICreateFile(ctx, "docs/update.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: defaultBranch, + NewBranchName: defaultBranch, + Message: "add docs/update.txt", + }, + ContentBase64: content, + }, func(t *testing.T, resp api.FileResponse) { + newCommit = resp.Commit.SHA + assert.NotEmpty(t, newCommit) + })(t) + + updateReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: newCommit, + OldCommitID: featureInitialCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, updateReq, http.StatusNoContent) + + t.Run("FastForwardApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + assert.Equal(t, newCommit, branch.Commit.ID) + })) + + staleReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: newCommit, + OldCommitID: featureInitialCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, staleReq, http.StatusConflict) + + nonFFReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: featureInitialCommit, + OldCommitID: newCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, nonFFReq, http.StatusConflict) + + forceReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: featureInitialCommit, + OldCommitID: newCommit, + Force: true, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, forceReq, http.StatusNoContent) + + t.Run("ForceApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + assert.Equal(t, featureInitialCommit, branch.Commit.ID) + })) + }) +} + func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to string, expectedHTTPStatus int) *httptest.ResponseRecorder { token := getUserToken(t, doerName, auth_model.AccessTokenScopeWriteRepository) req := NewRequestWithJSON(t, "PATCH", "api/v1/repos/"+ownerName+"/"+repoName+"/branches/"+from, &api.RenameBranchRepoOption{