mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
412 lines
16 KiB
Go
412 lines
16 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// validateBranch rejects invalid Git refs before making requests.
|
|
func validateBranch(name string) error {
|
|
invalid := name == "" || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") || strings.Contains(name, "@{") || strings.Contains(name, "//") || strings.ContainsAny(name, " ~^:?*[\\\t\r\n") ||
|
|
strings.ContainsFunc(name, func(r rune) bool { return r < 32 || r == 127 })
|
|
for part := range strings.SplitSeq(name, "/") {
|
|
invalid = invalid || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock")
|
|
}
|
|
if invalid {
|
|
return clierr.Wrap(clierr.Usage, errors.New("invalid branch name"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateBranches(names []string) error {
|
|
for _, name := range names {
|
|
if err := validateBranch(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *application) branchCommand() *cobra.Command {
|
|
group := &cobra.Command{
|
|
Use: "branch",
|
|
Short: "Manage remote branches and protection rules",
|
|
Long: `Work with the branches of a repository on the server: list and view them, create, rename and
|
|
delete them, and protect them from direct pushes. To work with local branches, use git.`,
|
|
}
|
|
group.AddCommand(a.branchListCommand(), a.branchViewCommand(), a.branchCreateCommand(), a.branchRenameCommand(), a.branchDeleteCommand())
|
|
group.AddCommand(a.branchProtectCommand(), a.branchUnprotectCommand())
|
|
return group
|
|
}
|
|
|
|
// branchURL links a branch's files on the web.
|
|
func branchURL(repository services.Repository, branch string) string {
|
|
if repository.URL == "" {
|
|
return ""
|
|
}
|
|
return repository.URL + "/src/branch/" + strings.ReplaceAll(url.PathEscape(branch), "%2F", "/")
|
|
}
|
|
|
|
func (a *application) branchListCommand() *cobra.Command {
|
|
var limit, page int
|
|
command := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List branches",
|
|
Example: `$ tea branch list
|
|
$ tea branch list -R alice/project --limit 100 --json name,sha`,
|
|
Args: argsExact(0),
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repository := services.Repository{}
|
|
if a.options.json == "" {
|
|
if repository, err = (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result, err := (services.Branches{Client: target.client}).ListPage(cmd.Context(), target.Repo, limit, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spec := listSpec[services.Branch]{
|
|
Columns: []terminal.Column{{Header: "Name"}, {Header: "Commit"}, {Header: "Protected"}, {Header: "Updated"}, {Header: "Message", Flex: true}},
|
|
Row: func(item services.Branch) []terminal.Cell {
|
|
name := terminal.Cell{Text: item.Name, Link: branchURL(repository, item.Name)}
|
|
// Piped output keeps the plain name and the full commit, for scripts.
|
|
sha := item.SHA
|
|
if a.deps.Streams.OutTTY {
|
|
sha = shortSHA(sha)
|
|
if item.Name == repository.DefaultBranch {
|
|
name.Text += " (default)"
|
|
name.Bold = true
|
|
}
|
|
}
|
|
protected := ""
|
|
if item.Protected {
|
|
protected = "protected"
|
|
}
|
|
message, _, _ := strings.Cut(item.Message, "\n")
|
|
return []terminal.Cell{name, {Text: sha, Muted: true}, {Text: protected, Color: "yellow"}, {Time: terminal.ParseTime(item.UpdatedAt)}, {Text: message}}
|
|
},
|
|
Context: "branches in " + target.Repo, Empty: "no branches in " + target.Repo,
|
|
}
|
|
return writeTable(a, cmd, result.Items, spec, servicePage(result, page))
|
|
},
|
|
}
|
|
pageFlags(command, &limit, &page)
|
|
return command
|
|
}
|
|
|
|
func (a *application) branchViewCommand() *cobra.Command {
|
|
var web bool
|
|
command := &cobra.Command{
|
|
Use: "view <branch>",
|
|
Short: "View a branch",
|
|
Example: "$ tea branch view main\n$ tea branch view feature/login --web",
|
|
Args: argsExact(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := validateBranch(args[0]); err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if web {
|
|
return a.openBrowser(cmd, branchURL(repository, args[0]))
|
|
}
|
|
item, err := (services.Branches{Client: target.client}).View(cmd.Context(), target.Repo, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeView(a, cmd, item, func(item services.Branch) terminal.View {
|
|
var badges []terminal.Cell
|
|
if item.Name == repository.DefaultBranch {
|
|
badges = append(badges, terminal.Cell{Text: "default", Color: "green"})
|
|
}
|
|
protection := ""
|
|
if item.Protected {
|
|
badges = append(badges, terminal.Cell{Text: "protected", Color: "yellow"})
|
|
protection = terminal.Plural(int(item.RequiredApprovals), "required approval")
|
|
}
|
|
meta := ""
|
|
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
|
|
meta = "updated " + terminal.RelativeTime(a.deps.Now(), updated)
|
|
}
|
|
return terminal.View{
|
|
Title: item.Name, Badges: badges, Meta: meta, Body: item.Message, Plain: true, Kind: "branch", URL: branchURL(repository, item.Name),
|
|
Fields: []terminal.Field{{Label: "Commit", Value: item.SHA}, {Label: "Protection", Value: protection}, {Label: "You can push", Value: yesNo(item.CanPush)}},
|
|
}
|
|
})
|
|
},
|
|
}
|
|
command.Flags().BoolVarP(&web, "web", "w", false, "Open the branch in the browser")
|
|
command.ValidArgsFunction = a.completeBranches()
|
|
return command
|
|
}
|
|
|
|
func (a *application) branchCreateCommand() *cobra.Command {
|
|
var source string
|
|
command := &cobra.Command{
|
|
Use: "create <branch>",
|
|
Short: "Create a branch",
|
|
Long: "Create a branch on the server from another branch, a tag or a commit; the default branch unless --source names one.",
|
|
Example: `# Branch off the default branch
|
|
$ tea branch create feature/login
|
|
|
|
# Branch off a tag or a commit
|
|
$ tea branch create hotfix/1.0.1 --source v1.0.0
|
|
$ tea branch create bisect --source 3f2a9c1`,
|
|
Args: argsExact(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := validateBranch(args[0]); err != nil {
|
|
return err
|
|
}
|
|
if cmd.Flags().Changed("source") && strings.TrimSpace(source) == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--source cannot be empty"))
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item, err := (services.Branches{Client: target.client}).Create(cmd.Context(), target.Repo, args[0], source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if source == "" {
|
|
a.done(cmd, "Created branch %s in %s at %s", item.Name, target.Repo, shortSHA(item.SHA))
|
|
} else {
|
|
a.done(cmd, "Created branch %s from %s in %s at %s", item.Name, source, target.Repo, shortSHA(item.SHA))
|
|
}
|
|
return a.jsonResult(cmd, item)
|
|
},
|
|
}
|
|
command.Flags().StringVarP(&source, "source", "s", "", "Branch, tag or commit to start from (default: the default branch)")
|
|
_ = command.RegisterFlagCompletionFunc("source", mergeCompletions(a.completeBranches(), a.completeTags()))
|
|
return command
|
|
}
|
|
|
|
func (a *application) branchRenameCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "rename <branch> <new-name>",
|
|
Short: "Rename a branch",
|
|
Example: "$ tea branch rename feature/logn feature/login",
|
|
Annotations: textOutput("branch rename does not return a resource"),
|
|
Args: argsExact(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := validateBranches(args); err != nil {
|
|
return err
|
|
}
|
|
if args[0] == args[1] {
|
|
return clierr.Wrap(clierr.Usage, errors.New("new branch name must differ from the current name"))
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := (services.Branches{Client: target.client}).Rename(cmd.Context(), target.Repo, args[0], args[1]); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Renamed branch %s to %s in %s", args[0], args[1], target.Repo)
|
|
return nil
|
|
},
|
|
}
|
|
command.ValidArgsFunction = a.completeBranches()
|
|
return command
|
|
}
|
|
|
|
func (a *application) branchDeleteCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "delete <branch>...",
|
|
Short: "Delete branches",
|
|
Example: `$ tea branch delete feature/login
|
|
$ tea branch delete old-a old-b --yes`,
|
|
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "branch deletion does not return a resource"},
|
|
Args: usageArgs(cobra.MinimumNArgs(1)),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := validateBranches(args); err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
subject := "branch " + args[0]
|
|
if len(args) > 1 {
|
|
subject = fmt.Sprintf("%d branches (%s)", len(args), strings.Join(args, ", "))
|
|
}
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + target.Repo + "?"}); err != nil {
|
|
return err
|
|
}
|
|
service := services.Branches{Client: target.client}
|
|
return batchDone(a, cmd, args, func(name string) string { return "delete branch " + name }, func(name string) (string, error) {
|
|
return name, service.Delete(cmd.Context(), target.Repo, name)
|
|
}, func(name string) string { return "Deleted branch " + name + " from " + target.Repo })
|
|
},
|
|
}
|
|
command.ValidArgsFunction = a.completeBranches()
|
|
return command
|
|
}
|
|
|
|
func (a *application) branchProtectCommand() *cobra.Command {
|
|
var rule services.Protection
|
|
var pattern string
|
|
var pushers []string
|
|
command := &cobra.Command{
|
|
Use: "protect <branch>...",
|
|
Short: "Protect branches",
|
|
Long: `Add a protection rule for each existing branch, or with --pattern for every branch matching a
|
|
glob. By default nobody can push directly and pull requests need one approving review.`,
|
|
Example: `# Require two approvals and passing CI on main
|
|
$ tea branch protect main --required-approvals 2 --status-check ci/build
|
|
|
|
# Let the release team push to every release branch
|
|
$ tea branch protect --pattern 'release/*' --allow-push acme/release`,
|
|
Annotations: textOutput("branch protect does not return a resource"),
|
|
Args: func(cmd *cobra.Command, args []string) error {
|
|
if pattern != "" {
|
|
if len(args) > 0 {
|
|
return clierr.Wrap(clierr.Usage, &argumentError{problem: "--pattern cannot be combined with branch names", cmd: cmd})
|
|
}
|
|
return nil
|
|
}
|
|
return usageArgs(cobra.MinimumNArgs(1))(cmd, args)
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := validateBranches(args); err != nil {
|
|
return err
|
|
}
|
|
if rule.RequiredApprovals < 0 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--required-approvals cannot be negative"))
|
|
}
|
|
if err := requireNames("push", "", pushers, rule.StatusChecks); err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner, _, _ := strings.Cut(target.Repo, "/")
|
|
for _, pusher := range pushers {
|
|
if org, team, ok := strings.Cut(pusher, "/"); ok {
|
|
// Gitea only looks teams up in the organization that owns the repository.
|
|
if !strings.EqualFold(org, owner) {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("team %s is not in %s, the owner of %s", pusher, owner, target.Repo))
|
|
}
|
|
rule.PushTeams = append(rule.PushTeams, team)
|
|
} else {
|
|
rule.PushUsers = append(rule.PushUsers, pusher)
|
|
}
|
|
}
|
|
service := services.Branches{Client: target.client}
|
|
rules := args
|
|
if pattern != "" {
|
|
rules = []string{pattern}
|
|
}
|
|
_, failures := applyBatch(cmd, rules, func(name string) string { return "protect " + name }, func(name string) (string, error) {
|
|
if pattern == "" {
|
|
if _, err := service.View(cmd.Context(), target.Repo, name); err != nil {
|
|
if services.IsNotFound(err) {
|
|
if _, repoErr := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo); repoErr != nil {
|
|
return "", repoErr
|
|
}
|
|
}
|
|
return "", services.Classify(err, "branch", strconv.Quote(name), target.Repo)
|
|
}
|
|
}
|
|
protection := rule
|
|
protection.Rule = name
|
|
created, err := service.Protect(cmd.Context(), target.Repo, protection)
|
|
if err == nil && !created {
|
|
a.warn(cmd, "%s is already protected in %s; change its rule on the web", name, target.Repo)
|
|
return "", nil
|
|
}
|
|
return name, err
|
|
}, func(name string) {
|
|
if name != "" {
|
|
a.done(cmd, "Protected %s in %s (%s)", name, target.Repo, protectionSummary(rule))
|
|
}
|
|
})
|
|
return errors.Join(failures...)
|
|
},
|
|
}
|
|
flags := command.Flags()
|
|
flags.StringVar(&pattern, "pattern", "", "Protect the branches matching this glob, such as release/*")
|
|
flags.Int64Var(&rule.RequiredApprovals, "required-approvals", 1, "Approving reviews that pull requests need")
|
|
flags.StringSliceVar(&pushers, "allow-push", nil, "Users, or teams as ORG/TEAM, that may push directly")
|
|
flags.StringSliceVar(&rule.StatusChecks, "status-check", nil, "Status checks that must pass (repeatable)")
|
|
flags.BoolVar(&rule.BlockOnRejected, "block-on-rejected-reviews", false, "Block merging while changes are requested")
|
|
flags.BoolVar(&rule.DismissStaleApprovals, "dismiss-stale-approvals", false, "Dismiss approvals when new commits are pushed")
|
|
_ = command.RegisterFlagCompletionFunc("allow-push", a.completeUsers())
|
|
command.ValidArgsFunction = a.completeBranches()
|
|
return command
|
|
}
|
|
|
|
// protectionSummary describes a protection rule, as in "1 approval required, no direct pushes".
|
|
func protectionSummary(rule services.Protection) string {
|
|
parts := []string{terminal.Plural(int(rule.RequiredApprovals), "approval") + " required"}
|
|
if pushers := len(rule.PushUsers) + len(rule.PushTeams); pushers > 0 {
|
|
parts = append(parts, "pushes by "+strings.Join(append(append([]string{}, rule.PushUsers...), rule.PushTeams...), ", "))
|
|
} else {
|
|
parts = append(parts, "no direct pushes")
|
|
}
|
|
if len(rule.StatusChecks) > 0 {
|
|
parts = append(parts, "checks "+strings.Join(rule.StatusChecks, ", "))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (a *application) branchUnprotectCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "unprotect <branch>...",
|
|
Short: "Remove branch protection",
|
|
Long: "Remove the protection rules of branches, or of glob patterns such as release/*.",
|
|
Example: "$ tea branch unprotect main\n$ tea branch unprotect 'release/*' --yes",
|
|
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "branch unprotect does not return a resource"},
|
|
Args: usageArgs(cobra.MinimumNArgs(1)),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
subject := "the protection of " + strings.Join(args, ", ")
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Remove " + subject + " in " + target.Repo + "?"}); err != nil {
|
|
return err
|
|
}
|
|
service := services.Branches{Client: target.client}
|
|
_, failures := applyBatch(cmd, args, func(name string) string { return "unprotect " + name }, func(name string) (string, error) {
|
|
removed, err := service.Unprotect(cmd.Context(), target.Repo, name)
|
|
if err == nil && !removed {
|
|
a.warn(cmd, "%s is not protected in %s", name, target.Repo)
|
|
return "", nil
|
|
}
|
|
return name, err
|
|
}, func(name string) {
|
|
if name != "" {
|
|
a.done(cmd, "Unprotected %s in %s", name, target.Repo)
|
|
}
|
|
})
|
|
return errors.Join(failures...)
|
|
},
|
|
}
|
|
command.ValidArgsFunction = a.completeBranches()
|
|
return command
|
|
}
|