feat(branch): branch from tags and commits, protect with explicit rules

branch create --source v1.0.0 (or a commit) failed with "The old branch
does not exist" because only old_branch_name was sent; the request now
sends old_ref_name, through a raw call until the SDK has the field.

branch protect nope exited 0 and created a rule for a missing branch,
and protecting twice failed with "permission denied". Branches must now
exist (--pattern protects a glob instead), an existing rule warns
"! main is already protected", and unprotecting an unprotected branch
warns instead of failing. The rule is explained in help and set with
--required-approvals, --allow-push, --status-check,
--block-on-rejected-reviews and --dismiss-stale-approvals.

branch list shows NAME (marking the default branch), COMMIT, PROTECTED,
UPDATED and MESSAGE, branch view is a full view with --web, branch
delete takes several branches, and every change reports a ✓ line.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:52:02 +02:00
1 parent 682c9f66a2
commit 02cf01093f
8 files changed
+555 -131

No files matched your search

+355 -89
View File
@@ -6,6 +6,7 @@ package app
import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
@@ -33,110 +34,375 @@ func validateBranch(name string) error {
return nil
}
// validateBranches validates every branch name.
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"}
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
list := &cobra.Command{Use: "list", Short: "List remote branches", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Branches{Client: target.client}).List(cmd.Context(), target.Repo, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, branchRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "rename", "delete", "protect", "unprotect"} {
var source string
count := 1
use := verb + " <name>"
if verb == "rename" {
count = 2
use = "rename <old-name> <new-name>"
}
arguments := argsExact(count)
if verb == "protect" || verb == "unprotect" {
use = verb + " <name>..."
arguments = usageArgs(cobra.MinimumNArgs(1))
}
command := &cobra.Command{Use: use, Short: titleCase(verb) + " a remote branch", Args: arguments, RunE: func(cmd *cobra.Command, args []string) error {
for _, name := range args {
if err := validateBranch(name); err != nil {
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
}
}
if source != "" {
if err := validateBranch(source); err != nil {
return err
}
result, err := (services.Branches{Client: target.client}).ListPage(cmd.Context(), target.Repo, limit, page)
if err != nil {
return err
}
if verb == "rename" && args[0] == args[1] {
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)}
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: shortSHA(item.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, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
},
}
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 = countNoun(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, 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.writeMutationJSON(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
}
service := services.Branches{Client: target.client}
if verb == "delete" || verb == "unprotect" {
if err := a.confirm(cmd, verb+" branches "+strings.Join(args, ", ")+" in "+target.Repo); err != nil {
return err
}
}
switch verb {
case "view":
item, err := service.View(cmd.Context(), target.Repo, args[0])
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "create":
item, err := service.Create(cmd.Context(), target.Repo, args[0], source)
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "rename":
return service.Rename(cmd.Context(), target.Repo, args[0], args[1])
case "delete":
return service.Delete(cmd.Context(), target.Repo, args[0])
case "protect", "unprotect":
var failures []error
for _, name := range args {
var err error
if verb == "protect" {
err = service.Protect(cmd.Context(), target.Repo, name)
} else {
err = service.Unprotect(cmd.Context(), target.Repo, name)
}
if err != nil {
failures = append(failures, fmt.Errorf("%s branch %s: %w", verb, name, err))
continue
}
if len(args) > 1 {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s branch %s\n", verb, name); err != nil {
return err
}
}
}
return errors.Join(failures...)
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
}}
if verb == "create" {
command.Flags().StringVar(&source, "source", "", "Source branch (default: repository default branch)")
}
if verb != "view" && verb != "create" {
command.Annotations = textOutput("branch " + verb + " does not return a resource")
}
group.AddCommand(command)
},
}
return group
command.ValidArgsFunction = a.completeBranches()
return command
}
func branchRow(item services.Branch) []terminal.Cell {
return terminal.Row(item.Name, item.SHA, strconv.FormatBool(item.Protected))
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 := branchSubject(args)
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + target.Repo + "?", NonTTY: "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
}
// branchSubject names one or more branches in a prompt: "branch main" or "2 branches (a, b)".
func branchSubject(names []string) string {
if len(names) == 1 {
return "branch " + names[0]
}
return fmt.Sprintf("%d branches (%s)", len(names), strings.Join(names, ", "))
}
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 branch. By default nobody can push to a protected branch directly
and pull requests need one approving review to merge; --allow-push lets users or teams push, and
--required-approvals changes the review count.
The branches must exist. --pattern protects every branch that matches a glob instead, such as
release/*, including branches created later.`,
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 != "" {
return usageArgs(cobra.NoArgs)(cmd, args)
}
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
}
for _, pusher := range pushers {
if _, team, ok := strings.Cut(pusher, "/"); ok {
rule.PushTeams = append(rule.PushTeams, team)
} else {
rule.PushUsers = append(rule.PushUsers, pusher)
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
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 {
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{countNoun(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 + "?", NonTTY: "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
}