mirror of https://github.com/go-gitea/gitea.git
Merge c8f5aa920c into de69e7f16a
commit
09a447ce38
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ type swaggerParameterBodies struct {
|
|||
|
||||
// in:body
|
||||
CreateBranchRepoOption api.CreateBranchRepoOption
|
||||
// in:body
|
||||
UpdateBranchRepoOption api.UpdateBranchRepoOption
|
||||
|
||||
// in:body
|
||||
CreateBranchProtectionOption api.CreateBranchProtectionOption
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Reference in New Issue