mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09: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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user