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

+18 -10
View File
@@ -130,15 +130,21 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
repo := ctx.Repo.Repository
gitRepo := ctx.Repo.GitRepo
objectFormat := ctx.Repo.GetObjectFormat()
isDeletingBranch := newCommitID == objectFormat.EmptyObjectID().String()
defaultBranch := repo.DefaultBranch
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
defaultBranch = repo.DefaultWikiBranch
}
if branchName == defaultBranch && newCommitID == objectFormat.EmptyObjectID().String() {
if isDeletingBranch && branchName == defaultBranch {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is the default branch and cannot be deleted", branchName)
return
}
if isDeletingBranch && branchName == repo.GetPullRequestTargetBranch(ctx) {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from deletion", branchName)
return
}
protectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
if err != nil {
@@ -156,9 +162,11 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
//
// First of all we need to enforce absolutely:
//
// 1. Detect and prevent deletion of the branch
if newCommitID == objectFormat.EmptyObjectID().String() {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from deletion", branchName)
// 1. Enforce branch deletion permissions
if isDeletingBranch {
if !protectBranch.CanUserDelete(ctx, ctx.Doer, ctx.Repo.Permission) {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from deletion", branchName)
}
return
}
@@ -199,7 +207,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// Now there are several tests which can be overridden:
//
// 4. Check protected file patterns - this is overridable from the UI
changedProtectedfiles := false
changedProtectedFiles := false
protectedFilePath := ""
globs := protectBranch.GetProtectedFilePatterns()
@@ -212,7 +220,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
return
}
changedProtectedfiles = true
changedProtectedFiles = true
protectedFilePath = errFilePathProtected.Path
}
}
@@ -220,9 +228,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
var canPush bool
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
canPush = !changedProtectedFiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
} else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
canPush = !changedProtectedFiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
}
// 6. If we're not allowed to push directly
@@ -232,7 +240,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// 6a. If we're not merging from the UI/API then there are two ways we got here:
//
// We are changing a protected file, and we're not allowed to do that
if changedProtectedfiles {
if changedProtectedFiles {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from changing file %s", branchName, protectedFilePath)
return
}
@@ -287,7 +295,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
// Now if we're not an admin - we can't overwrite protected files so fail now
if changedProtectedfiles {
if changedProtectedFiles {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from changing file %s", branchName, protectedFilePath)
return
}
+40 -16
View File
@@ -4,12 +4,14 @@
package private
import (
"net/http"
"testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -20,18 +22,27 @@ import (
"github.com/stretchr/testify/require"
)
func TestPreReceiveActionsProtectedBranch(t *testing.T) {
func TestPreReceiveActionsBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tc := range []struct {
name string
protection git_model.ProtectedBranch
forcePush bool
allowed bool
name string
protection *git_model.ProtectedBranchConfig
forcePush bool
deleteBranch bool
targetBranch bool
defaultBranch bool
allowed bool
}{
{name: "push", protection: git_model.ProtectedBranch{CanPush: true}, allowed: true},
{name: "push allowlist", protection: git_model.ProtectedBranch{CanPush: true, EnableWhitelist: true}},
{name: "force push", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
{name: "force push allowlist", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
{name: "push", protection: &git_model.ProtectedBranchConfig{CanPush: true}, allowed: true},
{name: "push allowlist", protection: &git_model.ProtectedBranchConfig{CanPush: true, EnableWhitelist: true}},
{name: "force push", protection: &git_model.ProtectedBranchConfig{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
{name: "force push allowlist", protection: &git_model.ProtectedBranchConfig{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
{name: "deletion disabled", protection: &git_model.ProtectedBranchConfig{CanPush: true}, deleteBranch: true},
{name: "deletion allowed", protection: &git_model.ProtectedBranchConfig{CanPush: true, CanDelete: true}, deleteBranch: true, allowed: true},
{name: "pull request target", protection: &git_model.ProtectedBranchConfig{CanPush: true, CanDelete: true}, deleteBranch: true, targetBranch: true},
{name: "unprotected deletion", deleteBranch: true, allowed: true},
{name: "unprotected pull request target", deleteBranch: true, targetBranch: true},
{name: "default branch", protection: &git_model.ProtectedBranchConfig{CanPush: true, CanDelete: true}, deleteBranch: true, defaultBranch: true},
} {
t.Run(tc.name, func(t *testing.T) {
mockCtx, resp := contexttest.MockPrivateContext(t, "/")
@@ -45,21 +56,34 @@ func TestPreReceiveActionsProtectedBranch(t *testing.T) {
doer := user_model.NewActionsUserWithTaskID(53)
loadContextDoerPermission(ctx.PrivateContext, doer.ID, doer.ExtDoerData.EncodeToString())
protection := tc.protection
protection.RepoID = ctx.Repo.Repository.ID
protection.RuleName = "probe"
require.NoError(t, db.Insert(t.Context(), &protection))
defer func() {
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
}()
if tc.protection != nil {
protection := git_model.ProtectedBranch{ProtectedBranchConfig: *tc.protection}
protection.RepoID = ctx.Repo.Repository.ID
protection.RuleName = "probe"
require.NoError(t, db.Insert(t.Context(), &protection))
defer func() {
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
}()
}
oldCommitID, newCommitID := "205ac761f3326a7ebe416e8673760016450b5cec", "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
if tc.forcePush {
oldCommitID, newCommitID = newCommitID, oldCommitID
}
if tc.deleteBranch {
newCommitID = ctx.Repo.GetObjectFormat().EmptyObjectID().String()
}
if tc.targetBranch {
ctx.Repo.Repository.Units = append(ctx.Repo.Repository.Units, &repo_model.RepoUnit{Type: unit.TypePullRequests, Config: &repo_model.PullRequestsConfig{DefaultTargetBranch: "probe"}})
}
if tc.defaultBranch {
ctx.Repo.Repository.DefaultBranch = "probe"
}
preReceiveBranch(ctx, oldCommitID, newCommitID, git.RefNameFromBranch("probe"))
if tc.allowed {
assert.False(t, ctx.Written(), resp.Body.String())
} else if tc.deleteBranch {
assert.Equal(t, http.StatusForbidden, resp.Code)
} else {
assert.Contains(t, resp.Body.String(), "Not allowed to")
}