mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-23 21:04:55 +02:00
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:
21 files changed
+600
-59
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user