0
0
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:
Lunny Xiao 2025-11-13 18:04:18 -08:00
parent 018156079b
commit f467a9d11b
8 changed files with 348 additions and 8 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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() {

View File

@ -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

View File

@ -147,6 +147,8 @@ type swaggerParameterBodies struct {
// in:body
CreateBranchRepoOption api.CreateBranchRepoOption
// in:body
UpdateBranchRepoOption api.UpdateBranchRepoOption
// in:body
CreateBranchProtectionOption api.CreateBranchProtectionOption

View File

@ -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

View File

@ -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": []
}
]
}
}

View File

@ -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{