Files
gitea-tea/internal/app/pull_clean.go
T
bircni 29f62da006 refactor: load settings once per run and reuse shared helpers
The application caches the settings snapshot and reloads it only after
an update through the store, so output and prompt helpers no longer
read and parse config.yml and hosts.yml several times per command.
canPrompt and chooser replace the inline prompt-policy checks.

Checks are tallied by one services.Tally, terminal.Plural replaces the
two app copies, refKind.article/noun replace the comment group's own
words, list commands use servicePage, and web URLs are built by
repositoryURL and webURL in browser.go.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:57:39 +02:00

164 lines
5.7 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullCleanCommand() *cobra.Command {
var ignoreSHA bool
cmd := &cobra.Command{Use: "clean [<number> | <url> | <branch>]", Short: "Delete the branches of a merged or closed pull request", Annotations: pullArgumentsAnnotation(map[string]string{"tea.confirm": "true"}), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
target, n, err := a.pullSelector(cmd, args)
if err != nil {
return err
}
snapshot, err := a.settings()
if err != nil {
return err
}
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
if repository.DefaultBranch == "" {
return clierr.Wrap(clierr.Usage, errors.New("repository has no default branch; cannot clean while checked out on the feature branch"))
}
pull, err := (services.Pulls{Client: target.client, Repository: target.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
if pull.State == "open" {
return errors.New("pull request is still open; close or merge it before cleaning branches")
}
if pull.Head == "" {
return errors.New("Gitea did not return the pull request head branch")
}
if !ignoreSHA && pull.HeadSHA == "" {
return errors.New("Gitea did not return the pull request head commit; retry with --ignore-sha")
}
matchingRemotes, err := a.remotesForRepositoryURLs(cmd.Context(), target.hosts(), pull.HeadCloneURL, pull.HeadSSHURL, pull.HeadRepo)
if err != nil {
return err
}
localBranch, remote, err := git.FindCleanBranch(cmd.Context(), a.deps.Git, git.FindCleanBranchOptions{
Directory: a.options.cwd, HeadSHA: pull.HeadSHA, HeadBranch: pull.Head, IgnoreSHA: ignoreSHA, DefaultBranch: repository.DefaultBranch,
PreferredNames: []string{fmt.Sprintf("pr/%d", n)}, MatchingRemotes: matchingRemotes,
})
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
deleteRemote := !pull.HeadRemoteDeleted && pull.HeadCanPush
if deleteRemote && remote == "" {
switch len(matchingRemotes) {
case 0:
return clierr.Wrap(clierr.Usage, errors.New("no Git remote matches the pull request head repository; add it before deleting the remote branch"))
case 1:
remote = matchingRemotes[0]
default:
return clierr.Wrap(clierr.Usage, errors.New("multiple Git remotes match the pull request head repository; select --remote"))
}
}
if a.options.remote != "" {
if deleteRemote && remote != "" && remote != a.options.remote {
return clierr.Wrap(clierr.Usage, errors.New("selected --remote does not match the pull request head remote"))
}
if remote == "" {
remote = a.options.remote
}
}
var credentialArgs []string
if deleteRemote {
if credentialArgs, err = a.remoteCredentials(cmd.Context(), target, remote); err != nil {
return err
}
}
description := fmt.Sprintf("Delete local branch %s of %s", localBranch, refLabel(target.Repo, n, pull.Title))
if deleteRemote {
description += fmt.Sprintf(" and remote branch %s/%s", remote, pull.Head)
}
prompt := description + "?"
if ignoreSHA {
prompt += " Local commits that are not in the pull request are lost."
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: prompt, NonTTY: strings.ToLower(description[:1]) + description[1:]}); err != nil {
return err
}
result, err := git.CleanPull(cmd.Context(), a.deps.Git, git.CleanOptions{
Directory: a.options.cwd, DefaultBranch: repository.DefaultBranch, LocalBranch: localBranch,
Remote: remote, RemoteBranch: pull.Head, CredentialArgs: credentialArgs,
Prompt: a.policy(snapshot).CanPrompt(a.deps.Streams), DeleteRemote: deleteRemote,
})
if err != nil {
return err
}
a.done(cmd, "Deleted local branch %s", result.LocalBranch)
switch {
case result.RemoteDeleted:
a.done(cmd, "Deleted remote branch %s/%s", result.Remote, result.RemoteBranch)
case pull.HeadRemoteDeleted:
a.warn(cmd, "Remote branch %s was already deleted", pull.Head)
case !pull.HeadCanPush:
a.warn(cmd, "Kept remote branch %s: you cannot push to %s", pull.Head, pull.HeadRepo)
}
return a.jsonResult(cmd, result)
}}
cmd.Flags().BoolVar(&ignoreSHA, "ignore-sha", false, "Find the local branch by name instead of commit hash")
cmd.ValidArgsFunction = a.completePulls("closed")
cmd.Example = `# After #13 was merged, delete its branch locally and on Gitea
$ tea pr clean 13`
return cmd
}
func (a *application) remotesForRepositoryURLs(ctx context.Context, hosts map[string]settings.Host, cloneURL, sshURL, fullName string) ([]string, error) {
names, err := a.git(ctx, "remote")
if err != nil {
return nil, err
}
wanted := map[string]bool{}
addWanted := func(host, repo string) {
wanted[host+"\t"+strings.ToLower(repo)] = true
}
for _, raw := range []string{cloneURL, sshURL} {
if raw == "" {
continue
}
host, repo, err := settings.RepositoryURL(raw, hosts)
if err != nil {
continue
}
addWanted(host, repo)
}
if fullName != "" {
for host := range hosts {
addWanted(host, fullName)
}
}
matches := []string{}
for name := range strings.FieldsSeq(names) {
raw, err := a.git(ctx, "remote", "get-url", name)
if err != nil {
return nil, err
}
host, repo, err := settings.RepositoryURL(raw, hosts)
if err != nil {
continue
}
if wanted[host+"\t"+strings.ToLower(repo)] {
matches = append(matches, name)
}
}
return matches, nil
}