Skip to content

Commit 360dd02

Browse files
author
Rob Gonnella
committed
feat: adds option to force update new branch in contents routes
Allows users to specify a "force" option in API /contents routes when modifying files in a new branch. When "force" is true, and the branch already exists, a force push will occur provided the branch does not have a branch protection rule that disables force pushing. This is useful as a way manage a branches remotes through only the API. For example in an automated release tool you can pull commits, analyze, and update a release PR branch all remotely without needing to clone or perform any local git operations. [#35538](#35538)
1 parent cdc0733 commit 360dd02

File tree

14 files changed

+301
-13
lines changed

14 files changed

+301
-13
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ __debug_bin*
2525
# Visual Studio
2626
/.vs/
2727

28+
# mise version managment tool
29+
mise.toml
30+
2831
*.cgo1.go
2932
*.cgo2.c
3033
_cgo_defun.c
@@ -121,4 +124,3 @@ prime/
121124
/AGENT.md
122125
/CLAUDE.md
123126
/llms.txt
124-

models/git/branch.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ func (err ErrBranchAlreadyExists) Unwrap() error {
6161
return util.ErrAlreadyExist
6262
}
6363

64+
// ErrBranchProtected represents an error that a branch cannot be force updated due to branch protections
65+
type ErrBranchProtected struct {
66+
BranchName string
67+
}
68+
69+
// IsErrBranchAlreadyExists checks if an error is an ErrBranchAlreadyExists.
70+
func IsErrBranchProtected(err error) bool {
71+
_, ok := err.(ErrBranchProtected)
72+
return ok
73+
}
74+
75+
func (err ErrBranchProtected) Error() string {
76+
return fmt.Sprintf("branch cannot be force updated due to branch protection rules [name: %s]", err.BranchName)
77+
}
78+
79+
func (err ErrBranchProtected) Unwrap() error {
80+
return util.ErrPermissionDenied
81+
}
82+
6483
// ErrBranchNameConflict represents an error that branch name conflicts with other branch.
6584
type ErrBranchNameConflict struct {
6685
BranchName string

modules/structs/repo_file.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type FileOptions struct {
1414
BranchName string `json:"branch" binding:"GitRefName;MaxSize(100)"`
1515
// new_branch (optional) will make a new branch from `branch` before creating the file
1616
NewBranchName string `json:"new_branch" binding:"GitRefName;MaxSize(100)"`
17+
// force (optional) will force update the new branch if it already exists
18+
Force bool `json:"force"`
1719
// `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
1820
Author Identity `json:"author"`
1921
Committer Identity `json:"committer"`

routers/api/v1/repo/file.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) {
355355
Message: commonOpts.Message,
356356
OldBranch: commonOpts.BranchName,
357357
NewBranch: commonOpts.NewBranchName,
358+
Force: commonOpts.Force,
358359
Committer: &files_service.IdentityOptions{
359360
GitUserName: commonOpts.Committer.Name,
360361
GitUserEmail: commonOpts.Committer.Email,
@@ -595,7 +596,7 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
595596
ctx.APIError(http.StatusForbidden, err)
596597
return
597598
}
598-
if git_model.IsErrBranchAlreadyExists(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) ||
599+
if git_model.IsErrBranchAlreadyExists(err) || git_model.IsErrBranchProtected(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) ||
599600
files_service.IsErrFilePathInvalid(err) || files_service.IsErrRepoFileAlreadyExists(err) ||
600601
files_service.IsErrCommitIDDoesNotMatch(err) || files_service.IsErrSHAOrCommitIDNotProvided(err) {
601602
ctx.APIError(http.StatusUnprocessableEntity, err)

services/packages/cargo/index.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ func alterRepositoryContent(ctx context.Context, doer *user_model.User, repo *re
306306
return err
307307
}
308308

309-
return t.Push(ctx, doer, commitHash, repo.DefaultBranch)
309+
return t.Push(ctx, doer, commitHash, repo.DefaultBranch, false)
310310
}
311311

312312
func writeObjectToIndex(ctx context.Context, t *files_service.TemporaryUploadRepository, path string, r io.Reader) error {

services/repository/files/cherry_pick.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
131131
}
132132

133133
// Then push this tree to NewBranch
134-
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
134+
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
135135
return nil, err
136136
}
137137

services/repository/files/patch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
201201
}
202202

203203
// Then push this tree to NewBranch
204-
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
204+
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
205205
return nil, err
206206
}
207207

services/repository/files/temp_repo.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,13 +354,14 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit
354354
}
355355

356356
// Push the provided commitHash to the repository branch by the provided user
357-
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string) error {
357+
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string, force bool) error {
358358
// Because calls hooks we need to pass in the environment
359359
env := repo_module.PushingEnvironment(doer, t.repo)
360360
if err := git.Push(ctx, t.basePath, git.PushOptions{
361361
Remote: t.repo.RepoPath(),
362362
Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch),
363363
Env: env,
364+
Force: force,
364365
}); err != nil {
365366
if git.IsErrPushOutOfDate(err) {
366367
return err

services/repository/files/update.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type ChangeRepoFilesOptions struct {
6060
Committer *IdentityOptions
6161
Dates *CommitDateOptions
6262
Signoff bool
63+
Force bool
6364
}
6465

6566
type RepoFileOptions struct {
@@ -168,19 +169,30 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
168169
}
169170

170171
// A NewBranch can be specified for the file to be created/updated in a new branch.
171-
// Check to make sure the branch does not already exist, otherwise we can't proceed.
172-
// If we aren't branching to a new branch, make sure user can commit to the given branch
172+
// Check to to see if the branch already exist. If it does, ensure Force option is set
173+
// and VerifyBranchProtection passes
173174
if opts.NewBranch != opts.OldBranch {
174175
exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.NewBranch)
175176
if err != nil {
176177
return nil, err
177178
}
179+
178180
if exist {
179-
return nil, git_model.ErrBranchAlreadyExists{
180-
BranchName: opts.NewBranch,
181+
if opts.Force {
182+
// ensure branch can be force pushed
183+
if err := VerifyBranchProtection(ctx, repo, doer, opts.NewBranch, treePaths, opts.Force); err != nil {
184+
return nil, git_model.ErrBranchProtected{
185+
BranchName: opts.NewBranch,
186+
}
187+
}
188+
} else {
189+
// branch exists but force option not set
190+
return nil, git_model.ErrBranchAlreadyExists{
191+
BranchName: opts.NewBranch,
192+
}
181193
}
182194
}
183-
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths); err != nil {
195+
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths, opts.Force); err != nil {
184196
return nil, err
185197
}
186198

@@ -303,7 +315,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
303315
}
304316

305317
// Then push this tree to NewBranch
306-
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
318+
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, opts.Force); err != nil {
307319
log.Error("%T %v", err, err)
308320
return nil, err
309321
}
@@ -685,7 +697,7 @@ func writeRepoObjectForRename(ctx context.Context, t *TemporaryUploadRepository,
685697
}
686698

687699
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
688-
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
700+
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string, force bool) error {
689701
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
690702
if err != nil {
691703
return err
@@ -695,6 +707,7 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do
695707
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
696708
globProtected := protectedBranch.GetProtectedFilePatterns()
697709
canUserPush := protectedBranch.CanUserPush(ctx, doer)
710+
canUserForcePush := protectedBranch.CanUserForcePush(ctx, doer)
698711
for _, treePath := range treePaths {
699712
isUnprotectedFile := false
700713
if len(globUnprotected) != 0 {
@@ -705,6 +718,11 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do
705718
UserName: doer.LowerName,
706719
}
707720
}
721+
if force && !canUserForcePush && !isUnprotectedFile {
722+
return ErrUserCannotCommit{
723+
UserName: doer.LowerName,
724+
}
725+
}
708726
if protectedBranch.IsProtectedFile(globProtected, treePath) {
709727
return pull_service.ErrFilePathProtected{
710728
Path: treePath,

templates/swagger/v1_json.tmpl

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)