feat(pulls): offer to delete the source branch after merging

Implements #1050.

Prompt to delete the source branch after a successful tea pr merge,
displaying the branch name because deletion cannot be undone.
--delete-branch skips the prompt for scripts; this flag was requested
in #810 for gh parity before being closed as an upstream Gitea bug.

PullDeleteBranch takes a confirmation callback rather than prompting
directly, fetching the pull request once and passing the branch name
to the caller. The callback runs after state and permission checks so
the user is not prompted for a deletion that would fail anyway.

Deletion uses the API against the head repository, so it works for
pull requests opened from a fork never checked out locally.
tea pr clean is unsuitable because it requires a local repo and fails
when the branch is not found locally. A matching local branch is still
removed as a best effort, matched by commit rather than name so a branch
with unpushed commits is left alone; PullClean makes the same choice
and only accepts name matching behind --ignore-sha.

The prompt is gated on stdin and stdout both being terminals, following
the existing check in modules/context. TeaContext.IsInteractiveMode
could not be used: despite its doc comment, it only reports whether any
flag was passed, so a script running a bare tea pr merge <idx> would
have blocked on a prompt it could not answer.

Branches already deleted server-side, PRs still open, and head
repositories the user cannot push to are each handled explicitly rather
than surfacing a raw API error.

Signed-off-by: Jan Baer <jan.s.baer@googlemail.com>
This commit is contained in:
Jan Baer 2026-09-01 09:00:22 +02:00
parent c2947c23d9
commit 5aedf30e7b
5 changed files with 302 additions and 4 deletions

View file

@ -40,6 +40,10 @@ var CmdPullsMerge = cli.Command{
Aliases: []string{"m"}, Aliases: []string{"m"},
Usage: "Merge commit message", Usage: "Merge commit message",
}, },
&cli.BoolFlag{
Name: "delete-branch",
Usage: "Delete the source branch after a successful merge, without asking",
},
}, flags.AllDefaultFlags...), }, flags.AllDefaultFlags...),
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
@ -63,10 +67,14 @@ var CmdPullsMerge = cli.Command{
return err return err
} }
return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{ if err := task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{
Style: gitea.MergeStyle(ctx.String("style")), Style: gitea.MergeStyle(ctx.String("style")),
Title: ctx.String("title"), Title: ctx.String("title"),
Message: ctx.String("message"), Message: ctx.String("message"),
}) }); err != nil {
return err
}
return interact.MaybeDeleteSourceBranch(requestCtx, ctx, idx, ctx.Bool("delete-branch"))
}, },
} }

View file

@ -515,6 +515,8 @@ Request changes to a pull request
Merge a pull request Merge a pull request
**--delete-branch**: Delete the source branch after a successful merge, without asking
**--login, -l**="": Use a different Gitea Login. Optional **--login, -l**="": Use a different Gitea Login. Optional
**--message, -m**="": Merge commit message **--message, -m**="": Merge commit message

View file

@ -6,6 +6,7 @@ package interact
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"os"
"strings" "strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
@ -13,9 +14,11 @@ import (
"gitea.dev/tea/cmd/flags" "gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context" "gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task" "gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils" "gitea.dev/tea/modules/utils"
"charm.land/huh/v2" "charm.land/huh/v2"
"golang.org/x/term"
) )
// MergePull interactively creates a PR // MergePull interactively creates a PR
@ -34,11 +37,15 @@ func MergePull(requestCtx stdctx.Context, ctx *context.TeaContext) error {
return err return err
} }
return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{ if err := task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{
Style: gitea.MergeStyle(ctx.String("style")), Style: gitea.MergeStyle(ctx.String("style")),
Title: ctx.String("title"), Title: ctx.String("title"),
Message: ctx.String("message"), Message: ctx.String("message"),
}) }); err != nil {
return err
}
return MaybeDeleteSourceBranch(requestCtx, ctx, idx, ctx.Bool("delete-branch"))
} }
// getPullIndex interactively determines the PR index // getPullIndex interactively determines the PR index
@ -106,3 +113,27 @@ func getPullIndex(requestCtx stdctx.Context, ctx *context.TeaContext, branch str
return idx, nil return idx, nil
} }
// MaybeDeleteSourceBranch deletes the source branch on --delete-branch, else
// asks. Needs a real terminal: IsInteractiveMode only counts flags.
func MaybeDeleteSourceBranch(requestCtx stdctx.Context, ctx *context.TeaContext, index int64, force bool) error {
confirm := confirmDeleteBranch
if force {
confirm = nil
} else if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return nil
}
return task.PullDeleteBranch(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, index, confirm)
}
// confirmDeleteBranch names the branch, since the delete is irreversible.
func confirmDeleteBranch(branch string) (bool, error) {
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Delete the source branch '%s'?", branch)).
Value(&confirmed).
WithTheme(theme.GetTheme()).
Run()
return confirmed, err
}

View file

@ -0,0 +1,96 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// PullDeleteBranch deletes a merged PR's source branch, and the local one if
// present. confirm, if non-nil, is asked with the branch name and may abort.
func PullDeleteBranch(requestCtx stdctx.Context, login *config.Login, repoOwner, repoName string, index int64, confirm func(branch string) (bool, error)) error {
client := login.Client()
pr, _, err := client.PullRequests.GetPullRequest(requestCtx, repoOwner, repoName, index)
if err != nil {
return err
}
if pr.State == gitea.StateOpen {
return fmt.Errorf("PR is still open, won't delete branches")
}
if pr.Head == nil || pr.Head.Repository == nil {
return fmt.Errorf("cannot determine the source branch of PR #%d", index)
}
if isRemoteDeleted(pr) {
fmt.Printf("Remote branch '%s' already deleted.\n", pr.Head.Name)
return nil
}
if pr.Head.Repository.Permissions == nil || !pr.Head.Repository.Permissions.Push {
return fmt.Errorf("no permission to delete branch '%s' in %s",
pr.Head.Ref, pr.Head.Repository.FullName)
}
if confirm != nil {
confirmed, err := confirm(pr.Head.Ref)
if err != nil {
return err
}
if !confirmed {
return nil
}
}
headOwner := pr.Head.Repository.Owner.UserName
headRepo := pr.Head.Repository.Name
fmt.Printf("Deleting remote branch %s\n", pr.Head.Ref)
if _, _, err := client.Repositories.DeleteRepoBranch(requestCtx, headOwner, headRepo, pr.Head.Ref); err != nil {
return err
}
if err := deleteLocalBranch(pr); err != nil {
fmt.Printf("Remote branch deleted, but the local branch could not be removed: %s\n", err)
}
return nil
}
// deleteLocalBranch removes the local branch at the PR's head commit. Matching
// by commit, not name, spares a branch with unpushed commits (as PullClean does).
func deleteLocalBranch(pr *gitea.PullRequest) error {
r, err := local_git.RepoForWorkdir()
if err != nil {
return nil
}
branch, err := r.TeaFindBranchBySha(pr.Head.Sha, pr.Head.Repository.CloneURL)
if err != nil {
return err
}
if branch == nil {
fmt.Printf("No local branch at %s, leaving local branches untouched.\n", pr.Head.Sha[:min(len(pr.Head.Sha), 10)])
return nil
}
// git won't delete the checked-out branch.
headRef, err := r.Head()
if err != nil {
return err
}
if headRef.Name().Short() == branch.Name {
base := pr.Base.Ref
fmt.Printf("Checking out '%s' to delete local branch '%s'\n", base, branch.Name)
if err := r.TeaCheckout(local_git.NewBranchReferenceName(base)); err != nil {
return err
}
}
fmt.Printf("Deleting local branch %s\n", branch.Name)
return r.TeaDeleteLocalBranch(branch)
}

View file

@ -0,0 +1,161 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testHeadSHA stands in for the head commit when no local repo is involved.
const testHeadSHA = "abc123"
// prJSON builds a merged PR with its head in headOwner/headRepo. A headRef of
// "refs/pull/7/head" is how Gitea reports an already-deleted branch.
func prJSON(headOwner, headRepo, headRef, headSHA string, canPush bool) string {
return fmt.Sprintf(`{
"number": 7,
"state": "closed",
"merged": true,
"base": {"ref": "main"},
"head": {
"label": "feature-x",
"ref": %q,
"sha": %q,
"repo": {
"name": %q,
"full_name": "%s/%s",
"owner": {"login": %q},
"clone_url": "https://example.invalid/%s/%s.git",
"permissions": {"admin": false, "push": %t, "pull": true}
}
}
}`, headRef, headSHA, headRepo, headOwner, headRepo, headOwner, headOwner, headRepo, canPush)
}
// deleteBranchServer serves the PR and records any branch DELETE, so tests can
// assert which repo it was addressed to.
func deleteBranchServer(t *testing.T, pr string, deleted *string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/branches/") {
*deleted = r.URL.Path
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(pr))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}))
}
func TestPullDeleteBranch(t *testing.T) {
tests := []struct {
name string
pr string
confirm func(branch string) (bool, error)
wantDeleted string
wantErr string
}{
{
// The branch is in the fork, so the DELETE must go there.
name: "deletes the head branch in the fork",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, true),
wantDeleted: "/api/v1/repos/contributor/tea/branches/feature-x",
},
{
name: "same-repo branch",
pr: prJSON("owner", "repo", "feature-x", testHeadSHA, true),
wantDeleted: "/api/v1/repos/owner/repo/branches/feature-x",
},
{
// Gitea removed it already: repo setting, or the web UI.
name: "already deleted upstream is a no-op",
pr: prJSON("contributor", "tea", "refs/pull/7/head", testHeadSHA, true),
},
{
name: "no push permission",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, false),
wantErr: "no permission to delete branch 'feature-x' in contributor/tea",
},
{
name: "PR still open",
pr: strings.Replace(prJSON("owner", "repo", "feature-x", testHeadSHA, true), `"state": "closed"`, `"state": "open"`, 1),
wantErr: "PR is still open, won't delete branches",
},
{
// The prompt gets the real name so it can say what it deletes.
name: "confirm is asked with the branch name",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, true),
confirm: func(branch string) (bool, error) {
if branch != "feature-x" {
return false, fmt.Errorf("confirm got branch %q, want feature-x", branch)
}
return true, nil
},
wantDeleted: "/api/v1/repos/contributor/tea/branches/feature-x",
},
{
name: "declining the prompt deletes nothing",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, true),
confirm: func(string) (bool, error) { return false, nil },
},
{
name: "confirm error aborts",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, true),
confirm: func(string) (bool, error) { return false, fmt.Errorf("interrupted") },
wantErr: "interrupted",
},
{
// Nothing is deletable, so don't ask.
name: "not asked when there is no push permission",
pr: prJSON("contributor", "tea", "feature-x", testHeadSHA, false),
confirm: func(string) (bool, error) {
t.Error("confirm called despite missing push permission")
return true, nil
},
wantErr: "no permission to delete branch 'feature-x' in contributor/tea",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Outside a repo, deleteLocalBranch is a no-op.
t.Chdir(t.TempDir())
var deleted string
server := deleteBranchServer(t, tt.pr, &deleted)
defer server.Close()
err := PullDeleteBranch(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 7, tt.confirm)
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
}
assert.Equal(t, tt.wantDeleted, deleted)
})
}
}