Skip to content

Commit f467a9d

Browse files
committed
Support updating branch via API
1 parent 0181560 commit f467a9d

File tree

8 files changed

+348
-8
lines changed

8 files changed

+348
-8
lines changed

modules/git/repo.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,18 +191,21 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
191191

192192
// PushOptions options when push to remote
193193
type PushOptions struct {
194-
Remote string
195-
Branch string
196-
Force bool
197-
Mirror bool
198-
Env []string
199-
Timeout time.Duration
194+
Remote string
195+
Branch string
196+
Force bool
197+
ForceWithLease string
198+
Mirror bool
199+
Env []string
200+
Timeout time.Duration
200201
}
201202

202203
// Push pushs local commits to given remote branch.
203204
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
204205
cmd := gitcmd.NewCommand("push")
205-
if opts.Force {
206+
if opts.ForceWithLease != "" {
207+
cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease)
208+
} else if opts.Force {
206209
cmd.AddArguments("-f")
207210
}
208211
if opts.Mirror {

modules/structs/repo.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,21 @@ type RenameBranchRepoOption struct {
292292
Name string `json:"name" binding:"Required;GitRefName;MaxSize(100)"`
293293
}
294294

295+
// UpdateBranchRepoOption options when updating a branch reference in a repository
296+
// swagger:model
297+
type UpdateBranchRepoOption struct {
298+
// New commit SHA (or any ref) the branch should point to
299+
//
300+
// required: true
301+
NewCommitID string `json:"new_commit_id" binding:"Required"`
302+
303+
// Expected old commit SHA of the branch; if provided it must match the current tip
304+
OldCommitID string `json:"old_commit_id"`
305+
306+
// Force update even if the change is not a fast-forward
307+
Force bool `json:"force"`
308+
}
309+
295310
// TransferRepoOption options when transfer a repository's ownership
296311
// swagger:model
297312
type TransferRepoOption struct {

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,7 @@ func Routes() *web.Router {
12391239
m.Get("/*", repo.GetBranch)
12401240
m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch)
12411241
m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch)
1242+
m.Put("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.UpdateBranchRepoOption{}), repo.UpdateBranch)
12421243
m.Patch("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.RenameBranchRepoOption{}), repo.RenameBranch)
12431244
}, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode))
12441245
m.Group("/branch_protections", func() {

routers/api/v1/repo/branch.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,91 @@ func ListBranches(ctx *context.APIContext) {
380380
ctx.JSON(http.StatusOK, apiBranches)
381381
}
382382

383+
// UpdateBranch moves a branch reference to a new commit.
384+
func UpdateBranch(ctx *context.APIContext) {
385+
// swagger:operation PUT /repos/{owner}/{repo}/branches/{branch} repository repoUpdateBranch
386+
// ---
387+
// summary: Update a branch reference to a new commit
388+
// consumes:
389+
// - application/json
390+
// produces:
391+
// - application/json
392+
// parameters:
393+
// - name: owner
394+
// in: path
395+
// description: owner of the repo
396+
// type: string
397+
// required: true
398+
// - name: repo
399+
// in: path
400+
// description: name of the repo
401+
// type: string
402+
// required: true
403+
// - name: branch
404+
// in: path
405+
// description: name of the branch
406+
// type: string
407+
// required: true
408+
// - name: body
409+
// in: body
410+
// schema:
411+
// "$ref": "#/definitions/UpdateBranchRepoOption"
412+
// responses:
413+
// "204":
414+
// "$ref": "#/responses/empty"
415+
// "403":
416+
// "$ref": "#/responses/forbidden"
417+
// "404":
418+
// "$ref": "#/responses/notFound"
419+
// "409":
420+
// "$ref": "#/responses/conflict"
421+
// "422":
422+
// "$ref": "#/responses/validationError"
423+
424+
opt := web.GetForm(ctx).(*api.UpdateBranchRepoOption)
425+
426+
branchName := ctx.PathParam("*")
427+
repo := ctx.Repo.Repository
428+
429+
if repo.IsEmpty {
430+
ctx.APIError(http.StatusNotFound, "Git Repository is empty.")
431+
return
432+
}
433+
434+
if repo.IsMirror {
435+
ctx.APIError(http.StatusForbidden, "Git Repository is a mirror.")
436+
return
437+
}
438+
439+
if ctx.Repo.GitRepo == nil {
440+
ctx.APIErrorInternal(nil)
441+
return
442+
}
443+
444+
if err := repo_service.UpdateBranch(ctx, repo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil {
445+
switch {
446+
case git_model.IsErrBranchNotExist(err):
447+
ctx.APIError(http.StatusNotFound, "Branch doesn't exist.")
448+
case repo_service.IsErrBranchCommitDoesNotMatch(err):
449+
ctx.APIError(http.StatusConflict, err)
450+
case git.IsErrPushOutOfDate(err):
451+
ctx.APIError(http.StatusConflict, "The update is not a fast-forward.")
452+
case git.IsErrPushRejected(err):
453+
rej := err.(*git.ErrPushRejected)
454+
ctx.APIError(http.StatusForbidden, rej.Message)
455+
case repo_model.IsErrUserDoesNotHaveAccessToRepo(err):
456+
ctx.APIError(http.StatusForbidden, err)
457+
case git.IsErrNotExist(err):
458+
ctx.APIError(http.StatusUnprocessableEntity, err)
459+
default:
460+
ctx.APIErrorInternal(err)
461+
}
462+
return
463+
}
464+
465+
ctx.Status(http.StatusNoContent)
466+
}
467+
383468
// RenameBranch renames a repository's branch.
384469
func RenameBranch(ctx *context.APIContext) {
385470
// swagger:operation PATCH /repos/{owner}/{repo}/branches/{branch} repository repoRenameBranch

routers/api/v1/swagger/options.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ type swaggerParameterBodies struct {
147147

148148
// in:body
149149
CreateBranchRepoOption api.CreateBranchRepoOption
150+
// in:body
151+
UpdateBranchRepoOption api.UpdateBranchRepoOption
150152

151153
// in:body
152154
CreateBranchProtectionOption api.CreateBranchProtectionOption

services/repository/branch.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,8 +483,107 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
483483
return "", nil
484484
}
485485

486+
// UpdateBranch moves a branch reference to the provided commit.
487+
func UpdateBranch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName, newCommitID, expectedOldCommitID string, force bool) error {
488+
if err := repo.MustNotBeArchived(); err != nil {
489+
return err
490+
}
491+
492+
perm, err := access_model.GetUserRepoPermission(ctx, repo, doer)
493+
if err != nil {
494+
return err
495+
}
496+
if !perm.CanWrite(unit.TypeCode) {
497+
return repo_model.ErrUserDoesNotHaveAccessToRepo{
498+
UserID: doer.ID,
499+
RepoName: repo.LowerName,
500+
}
501+
}
502+
503+
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
504+
if err != nil {
505+
return fmt.Errorf("OpenRepository: %w", err)
506+
}
507+
defer gitRepo.Close()
508+
509+
branchCommit, err := gitRepo.GetBranchCommit(branchName)
510+
if err != nil {
511+
if git.IsErrNotExist(err) {
512+
return git_model.ErrBranchNotExist{RepoID: repo.ID, BranchName: branchName}
513+
}
514+
return err
515+
}
516+
currentCommitID := branchCommit.ID.String()
517+
518+
if expectedOldCommitID != "" {
519+
expectedID, err := gitRepo.ConvertToGitID(expectedOldCommitID)
520+
if err != nil {
521+
return fmt.Errorf("ConvertToGitID(old): %w", err)
522+
}
523+
if expectedID.String() != currentCommitID {
524+
return ErrBranchCommitDoesNotMatch{Expected: currentCommitID, Given: expectedID.String()}
525+
}
526+
}
527+
528+
newID, err := gitRepo.ConvertToGitID(newCommitID)
529+
if err != nil {
530+
return fmt.Errorf("ConvertToGitID(new): %w", err)
531+
}
532+
newCommit, err := gitRepo.GetCommit(newID.String())
533+
if err != nil {
534+
return err
535+
}
536+
537+
if newCommit.ID.String() == currentCommitID {
538+
return nil
539+
}
540+
541+
isForcePush, err := newCommit.IsForcePush(currentCommitID)
542+
if err != nil {
543+
return err
544+
}
545+
if isForcePush && !force {
546+
return &git.ErrPushOutOfDate{Err: errors.New("non fast-forward update requires force"), StdErr: "non-fast-forward", StdOut: ""}
547+
}
548+
549+
pushOpts := git.PushOptions{
550+
Remote: repo.RepoPath(),
551+
Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName),
552+
Env: repo_module.PushingEnvironment(doer, repo),
553+
}
554+
555+
if expectedOldCommitID != "" {
556+
pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, currentCommitID)
557+
}
558+
if isForcePush || force {
559+
pushOpts.Force = true
560+
}
561+
562+
if err := git.Push(ctx, repo.RepoPath(), pushOpts); err != nil {
563+
return err
564+
}
565+
566+
return nil
567+
}
568+
486569
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default")
487570

571+
// ErrBranchCommitDoesNotMatch indicates the provided old commit id does not match the branch tip.
572+
type ErrBranchCommitDoesNotMatch struct {
573+
Expected string
574+
Given string
575+
}
576+
577+
// IsErrBranchCommitDoesNotMatch checks if the error is ErrBranchCommitDoesNotMatch.
578+
func IsErrBranchCommitDoesNotMatch(err error) bool {
579+
_, ok := err.(ErrBranchCommitDoesNotMatch)
580+
return ok
581+
}
582+
583+
func (e ErrBranchCommitDoesNotMatch) Error() string {
584+
return fmt.Sprintf("branch commit does not match [expected: %s, given: %s]", e.Expected, e.Given)
585+
}
586+
488587
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
489588
if branchName == repo.DefaultBranch {
490589
return ErrBranchIsDefault

templates/swagger/v1_json.tmpl

Lines changed: 61 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)