Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions modules/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
85 changes: 85 additions & 0 deletions routers/api/v1/repo/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ type swaggerParameterBodies struct {

// in:body
CreateBranchRepoOption api.CreateBranchRepoOption
// in:body
UpdateBranchRepoOption api.UpdateBranchRepoOption

// in:body
CreateBranchProtectionOption api.CreateBranchProtectionOption
Expand Down
143 changes: 143 additions & 0 deletions services/repository/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading