mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-15 12:22:55 +01:00
Support updating branch via API
This commit is contained in:
parent
018156079b
commit
f467a9d11b
@ -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
|
||||
|
||||
@ -483,8 +483,107 @@ 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: ""}
|
||||
}
|
||||
|
||||
pushOpts := git.PushOptions{
|
||||
Remote: repo.RepoPath(),
|
||||
Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
}
|
||||
|
||||
if expectedOldCommitID != "" {
|
||||
pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, currentCommitID)
|
||||
}
|
||||
if isForcePush || force {
|
||||
pushOpts.Force = true
|
||||
}
|
||||
|
||||
if err := git.Push(ctx, repo.RepoPath(), pushOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
62
templates/swagger/v1_json.tmpl
generated
62
templates/swagger/v1_json.tmpl
generated
@ -6797,6 +6797,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@ -30407,4 +30467,4 @@
|
||||
"TOTPHeader": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…
x
Reference in New Issue
Block a user