Lunny Xiao 2025-11-18 09:58:37 -08:00 committed by GitHub
commit 09a447ce38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 416 additions and 7 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

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

View File

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

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{