feat(repo): allow deleting protected branches (#39243)

This adds a Branch Deletion setting with three modes: disabled by
default, anyone with push access, or a restricted user/team/deploy-key
allowlist. The policy applies to Git pushes, API/UI deletion, and
post-merge cleanup; the default branch and the pull request target
branch remain undeletable.

Closes https://github.com/go-gitea/gitea/issues/33305

---------

Co-authored-by: Enoque Duarte <12515551+enoquefcd@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 07:19:49 +00:00
1 parent aebdbd85fb
commit b015cd67e2
21 files changed
+600 -59

No files matched your search

+36 -11
View File
@@ -50,6 +50,7 @@ func CreateNewBranch(ctx context.Context, doer *user_model.User, repo *repo_mode
type Branch struct {
DBBranch *git_model.Branch
IsProtected bool
CanDelete bool
IsIncluded bool
CommitsAhead int
CommitsBehind int
@@ -58,7 +59,7 @@ type Branch struct {
}
// LoadBranches loads branches from the repository limited by page & pageSize.
func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (defaultBranchOptional *Branch, _ []*Branch, _ int64, _ error) {
func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, permissionInRepo access_model.Permission, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (defaultBranchOptional *Branch, _ []*Branch, _ int64, _ error) {
defaultDBBranchOptional, err := git_model.GetBranchExisting(ctx, repo.ID, repo.DefaultBranch)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return nil, nil, 0, err
@@ -91,16 +92,18 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git
if err != nil {
return nil, nil, 0, err
}
pullRequestTargetBranch := repo.GetPullRequestTargetBranch(ctx)
repoIDToRepo := map[int64]*repo_model.Repository{}
repoIDToRepo[repo.ID] = repo
repoIDToGitRepo := map[int64]*git.Repository{}
repoIDToGitRepo[repo.ID] = gitRepo
canDeleteProtectedBranches := make(map[int64]bool, len(rules))
branches := make([]*Branch, 0, len(dbBranches))
for i := range dbBranches {
branch, err := loadOneBranch(ctx, repo, dbBranches[i], &rules, repoIDToRepo, repoIDToGitRepo)
branch, err := loadOneBranch(ctx, repo, dbBranches[i], &rules, repoIDToRepo, repoIDToGitRepo, doer, permissionInRepo, canDeleteProtectedBranches, pullRequestTargetBranch)
if err != nil {
return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err)
}
@@ -109,7 +112,7 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git
if defaultDBBranchOptional != nil {
// Always add the default branch
defaultBranchOptional, err = loadOneBranch(ctx, repo, defaultDBBranchOptional, &rules, repoIDToRepo, repoIDToGitRepo)
defaultBranchOptional, err = loadOneBranch(ctx, repo, defaultDBBranchOptional, &rules, repoIDToRepo, repoIDToGitRepo, doer, permissionInRepo, canDeleteProtectedBranches, pullRequestTargetBranch)
if err != nil {
return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err)
}
@@ -170,12 +173,26 @@ func DelRepoDivergenceFromCache(ctx context.Context, repoID int64) error {
func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *git_model.Branch, protectedBranches *git_model.ProtectedBranchRules,
repoIDToRepo map[int64]*repo_model.Repository,
repoIDToGitRepo map[int64]*git.Repository,
doer *user_model.User,
permissionInRepo access_model.Permission,
canDeleteProtectedBranches map[int64]bool,
pullRequestTargetBranch string,
) (*Branch, error) {
log.Trace("loadOneBranch: '%s'", dbBranch.Name)
branchName := dbBranch.Name
p := protectedBranches.GetFirstMatched(branchName)
isProtected := p != nil
canDelete := !dbBranch.IsDeleted && repo.DefaultBranch != branchName && pullRequestTargetBranch != branchName
if isProtected && canDelete {
p.Repo = repo
canUserDelete, ok := canDeleteProtectedBranches[p.ID]
if !ok {
canUserDelete = p.CanUserDelete(ctx, doer, permissionInRepo)
canDeleteProtectedBranches[p.ID] = canUserDelete
}
canDelete = canDelete && canUserDelete
}
var divergence *git.DivergeObject
@@ -247,6 +264,7 @@ func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *g
return &Branch{
DBBranch: dbBranch,
IsProtected: isProtected,
CanDelete: canDelete,
IsIncluded: isIncluded,
CommitsAhead: divergence.Ahead,
CommitsBehind: divergence.Behind,
@@ -545,24 +563,31 @@ func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default or pull request target")
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
if branchName == repo.DefaultBranch || branchName == unitPRConfig.DefaultTargetBranch {
return ErrBranchIsDefault
}
perm, err := access_model.GetDoerRepoPermission(ctx, repo, doer)
if err != nil {
return err
}
if !perm.CanWrite(unit.TypeCode) {
return CanDeleteBranchWithPermission(ctx, repo, branchName, doer, perm)
}
// CanDeleteBranchWithPermission checks whether a branch can be deleted using an already loaded repository permission.
func CanDeleteBranchWithPermission(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User, permissionInRepo access_model.Permission) error {
if branchName == repo.DefaultBranch || branchName == repo.GetPullRequestTargetBranch(ctx) {
return ErrBranchIsDefault
}
if !permissionInRepo.CanWrite(unit.TypeCode) {
return util.NewPermissionDeniedErrorf("permission denied to access repo %d unit %s", repo.ID, unit.TypeCode.LogString())
}
isProtected, err := git_model.IsBranchProtected(ctx, repo.ID, branchName)
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
if err != nil {
return err
}
if isProtected {
if protectedBranch != nil {
protectedBranch.Repo = repo
if protectedBranch.CanUserDelete(ctx, doer, permissionInRepo) {
return nil
}
return git_model.ErrBranchIsProtected
}
return nil
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCanDeleteBranchDefaultTargets(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
rule := &git_model.ProtectedBranch{
RepoID: repo.ID, RuleName: "*",
ProtectedBranchConfig: git_model.ProtectedBranchConfig{CanPush: true, CanDelete: true},
}
require.NoError(t, db.Insert(ctx, rule))
assert.NoError(t, CanDeleteBranch(ctx, repo, "branch2", owner))
assert.ErrorIs(t, CanDeleteBranch(ctx, repo, repo.DefaultBranch, owner), ErrBranchIsDefault)
pullRequestUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
assert.NoError(t, err)
pullRequestConfig := pullRequestUnit.PullRequestsConfig()
pullRequestConfig.DefaultTargetBranch = "branch2"
pullRequestUnit.Config = pullRequestConfig
assert.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, pullRequestUnit))
repo.Units = nil
assert.ErrorIs(t, CanDeleteBranch(ctx, repo, "branch2", owner), ErrBranchIsDefault)
}