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

+10 -1
View File
@@ -65,7 +65,7 @@ func testAPICreateBranchProtection(t *testing.T, branchName string, expectedPrio
}
}
func testAPIEditBranchProtection(t *testing.T, branchName string, body *api.BranchProtection, expectedHTTPStatus int) {
func testAPIEditBranchProtection(t *testing.T, branchName string, body any, expectedHTTPStatus int) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1/branch_protections/"+branchName, body).
AddTokenAuth(token)
@@ -406,6 +406,15 @@ func testAPIBranchProtectionBasic(t *testing.T) {
// Test branch deletion
testAPIDeleteBranch(t, "master", http.StatusForbidden)
testAPICreateBranchProtection(t, "branch2", 3, http.StatusCreated)
testAPIDeleteBranch(t, "branch2", http.StatusForbidden)
testAPIEditBranchProtection(t, "branch2", &api.EditBranchProtectionOption{
EnablePush: new(true), EnableDeletion: new(true), EnableDeletionAllowlist: new(true),
DeletionAllowlistUsernames: []string{"user2"},
}, http.StatusOK)
bp = testAPIGetBranchProtection(t, "branch2", http.StatusOK)
assert.True(t, bp.EnableDeletion)
assert.Equal(t, []string{"user2"}, bp.DeletionAllowlistUsernames)
testAPIDeleteBranch(t, "branch2", http.StatusNoContent)
testAPIDeleteBranch(t, "branch2", http.StatusNotFound) // deleted branch, there is a record in DB with IsDelete=true
testAPIDeleteBranch(t, "no-such-branch", http.StatusNotFound) // non-existing branch, not exist in git or DB
+37
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
api "gitea.dev/modules/structs"
"gitea.dev/modules/translation"
"gitea.dev/tests"
@@ -70,3 +71,39 @@ func TestUndoDeleteBranch(t *testing.T) {
)
})
}
func TestDeleteProtectedBranchWeb(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user2")
const branchURL = "/user2/repo1/branches/delete?name=branch2"
const deleteButton = `.delete-branch-button[data-modal-name="branch2"]`
testAPICreateBranchProtection(t, "branch2", 1, http.StatusCreated)
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/branches"), http.StatusOK)
AssertHTMLElement(t, NewHTMLParser(t, resp.Body), deleteButton, 0)
session.MakeRequest(t, NewRequest(t, "POST", branchURL), http.StatusBadRequest)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1/branches/branch2"), http.StatusOK)
testAPIEditBranchProtection(t, "branch2", &api.EditBranchProtectionOption{
EnablePush: new(true),
EnableDeletion: new(true),
EnableDeletionAllowlist: new(true),
DeletionAllowlistUsernames: []string{"user2"},
}, http.StatusOK)
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/branches"), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, deleteButton, 1)
button := htmlDoc.doc.Find(deleteButton)
assert.Equal(t, "button", button.AttrOr("type", ""))
link, exists := button.Attr("data-modal-form.url")
require.True(t, exists)
resp = session.MakeRequest(t, NewRequest(t, "POST", link), http.StatusOK)
assert.JSONEq(t, `{"redirect":""}`, resp.Body.String())
testAPIGetBranch(t, "branch2", false)
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/branches"), http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, deleteButton, 0)
AssertHTMLElement(t, htmlDoc, `.restore-branch-button[data-url*="name=branch2"]`, 0)
})
}
+18 -1
View File
@@ -486,6 +486,16 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
t.Run("MergeProtectedToToforce", doGitMerge(dstPath, "protected"))
t.Run("PushToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "toforce:protected"))
t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master"))
t.Run("CreateBranchForProtectedDeletion", doGitCreateBranch(dstPath, "protected-delete"))
t.Run("PushBranchForProtectedDeletion", doGitPushTestRepository(dstPath, "origin", "protected-delete"))
t.Run("ProtectBranchWithoutDeletion", doProtectBranch(ctx, "protected-delete", baseCtx.Username, "", "", ""))
t.Run("DeleteProtectedBranchDenied", doGitPushTestRepositoryFail(dstPath, "origin", "--delete", "protected-delete"))
t.Run("ProtectBranchWithDeletionAllowlist", doProtectBranchExt(ctx, "protected-delete", doProtectBranchOptions{
UserToWhitelistPush: baseCtx.Username,
UserToWhitelistDelete: baseCtx.Username,
}))
t.Run("DeleteProtectedBranchAllowed", doGitPushTestRepository(dstPath, "origin", "--delete", "protected-delete"))
}
}
@@ -499,7 +509,7 @@ func doProtectBranch(ctx APITestContext, branch, userToWhitelistPush, userToWhit
}
type doProtectBranchOptions struct {
UserToWhitelistPush, UserToWhitelistForcePush, UnprotectedFilePatterns, ProtectedFilePatterns string
UserToWhitelistPush, UserToWhitelistForcePush, UserToWhitelistDelete, UnprotectedFilePatterns, ProtectedFilePatterns string
StatusCheckPatterns []string
}
@@ -529,6 +539,13 @@ func doProtectBranchExt(ctx APITestContext, ruleName string, opts doProtectBranc
formData["enable_force_push_allowlist"] = "on"
}
if opts.UserToWhitelistDelete != "" {
user, err := user_model.GetUserByName(t.Context(), opts.UserToWhitelistDelete)
assert.NoError(t, err)
formData["deletion_allowlist_users"] = strconv.FormatInt(user.ID, 10)
formData["enable_deletion"] = "whitelist"
}
if len(opts.StatusCheckPatterns) > 0 {
formData["enable_status_check"] = "on"
formData["status_check_contexts"] = strings.Join(opts.StatusCheckPatterns, "\n")