diff --git a/cmd/pulls/merge.go b/cmd/pulls/merge.go index a08d0606..ecb344e5 100644 --- a/cmd/pulls/merge.go +++ b/cmd/pulls/merge.go @@ -40,6 +40,10 @@ var CmdPullsMerge = cli.Command{ Aliases: []string{"m"}, Usage: "Merge commit message", }, + &cli.BoolFlag{ + Name: "delete-branch", + Usage: "Delete the source branch after a successful merge, without asking", + }, }, flags.AllDefaultFlags...), Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { ctx, err := context.InitCommand(cmd) @@ -63,10 +67,14 @@ var CmdPullsMerge = cli.Command{ 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")), Title: ctx.String("title"), Message: ctx.String("message"), - }) + }); err != nil { + return err + } + + return interact.MaybeDeleteSourceBranch(requestCtx, ctx, idx, ctx.Bool("delete-branch")) }, } diff --git a/docs/CLI.md b/docs/CLI.md index 2363574b..e601b439 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -515,6 +515,8 @@ Request changes to 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 **--message, -m**="": Merge commit message diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 2cbd4a06..2e7be316 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -6,6 +6,7 @@ package interact import ( stdctx "context" "fmt" + "os" "strings" gitea "gitea.dev/sdk" @@ -13,9 +14,11 @@ import ( "gitea.dev/tea/cmd/flags" "gitea.dev/tea/modules/context" "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/theme" "gitea.dev/tea/modules/utils" "charm.land/huh/v2" + "golang.org/x/term" ) // MergePull interactively creates a PR @@ -34,11 +37,15 @@ func MergePull(requestCtx stdctx.Context, ctx *context.TeaContext) error { 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")), Title: ctx.String("title"), Message: ctx.String("message"), - }) + }); err != nil { + return err + } + + return MaybeDeleteSourceBranch(requestCtx, ctx, idx, ctx.Bool("delete-branch")) } // getPullIndex interactively determines the PR index @@ -106,3 +113,27 @@ func getPullIndex(requestCtx stdctx.Context, ctx *context.TeaContext, branch str 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 +} diff --git a/modules/task/pull_delete_branch.go b/modules/task/pull_delete_branch.go new file mode 100644 index 00000000..34acc3df --- /dev/null +++ b/modules/task/pull_delete_branch.go @@ -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) +} diff --git a/modules/task/pull_delete_branch_test.go b/modules/task/pull_delete_branch_test.go new file mode 100644 index 00000000..17cc715d --- /dev/null +++ b/modules/task/pull_delete_branch_test.go @@ -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) + }) + } +}