fix(repo): reject --repo on commands that act on no repository

tea repo list -R acme/service listed the account's repositories and
tea repo create foo -R alice/project created alice/foo: the flag was
silently ignored. repo list, repo create, repo migrate and search repo
now fail with a usage error that suggests the intended command, such as
"--repo is not used by repo list; did you mean: tea repo list acme".

repo list takes the owner as an argument (--owner still works), and
repo create accepts OWNER/NAME, creating in the organization OWNER.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:43 +02:00
1 parent ea2e9aadbb
commit 2330d174ea
7 files changed
+96 -7

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ const (
// help omits --repo, --remote and --cwd. // help omits --repo, --remote and --cwd.
var noRepoCommands = []string{ var noRepoCommands = []string{
"tea admin", "tea alias", "tea auth", "tea completion", "tea config", "tea help", "tea man", "tea org", "tea admin", "tea alias", "tea auth", "tea completion", "tea config", "tea help", "tea man", "tea org",
"tea repo create", "tea repo list", "tea search repo", "tea ssh-key", "tea team", "tea version", "tea repo create", "tea repo list", "tea repo migrate", "tea search repo", "tea ssh-key", "tea team", "tea version",
} }
// writeHelp prints the help page of cmd. // writeHelp prints the help page of cmd.
+25 -1
View File
@@ -5,9 +5,11 @@ package app
import ( import (
"errors" "errors"
"strings"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -24,10 +26,32 @@ func (a *application) repositoryArgument(args []string) error {
return nil return nil
} }
// rejectRepoFlag fails when --repo is passed to a command that acts on no repository; suggest
// words the command the user probably meant for the owner that --repo names.
func (a *application) rejectRepoFlag(cmd *cobra.Command, suggest func(owner string) string) error {
if a.options.repo == "" {
return nil
}
text := "--repo is not used by " + strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")
if ref, err := settings.ParseRepository(a.options.repo, nil); err == nil && ref.Owner != "" {
text += "; did you mean: " + suggest(ref.Owner)
}
return clierr.Wrap(clierr.Usage, errors.New(text))
}
func (a *application) repoCommand() *cobra.Command { func (a *application) repoCommand() *cobra.Command {
group := &cobra.Command{Use: "repo", Short: "Work with Gitea repositories"} group := &cobra.Command{Use: "repo", Short: "Work with Gitea repositories"}
var opts services.RepositoryListOptions var opts services.RepositoryListOptions
list := &cobra.Command{Use: "list", Short: "List your repositories", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { list := &cobra.Command{Use: "list [<owner>]", Short: "List your repositories", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo list " + owner }); err != nil {
return err
}
if len(args) > 0 {
if cmd.Flags().Changed("owner") {
return clierr.Wrap(clierr.Usage, errors.New("supply the owner as an argument or --owner, not both"))
}
opts.Owner = args[0]
}
if err := validateRepositoryKind(opts.Kind); err != nil { if err := validateRepositoryKind(opts.Kind); err != nil {
return err return err
} }
+46
View File
@@ -6,6 +6,7 @@ package app
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -40,3 +41,48 @@ func TestRepositoryCommandsAcceptPositionalRepository(t *testing.T) {
}) })
} }
} }
func TestCommandsWithoutRepositoryRejectRepoFlag(t *testing.T) {
for _, test := range []struct {
args []string
want string
}{
{[]string{"repo", "list", "-R", "acme/service"}, "--repo is not used by repo list; did you mean: tea repo list acme"},
{[]string{"repo", "create", "foo", "-R", "alice/project", "--yes"}, "--repo is not used by repo create; did you mean: tea repo create alice/foo"},
{[]string{"repo", "migrate", "https://example.com/x.git", "--name", "x", "--repo", "acme/x"}, "--repo is not used by repo migrate; did you mean: tea repo migrate URL --owner acme"},
{[]string{"search", "repo", "tea", "-R", "acme/x"}, "--repo is not used by search repo; did you mean: tea search repo --owner acme"},
} {
t.Run(strings.Join(test.args[:2], " "), func(t *testing.T) {
requireUsageBeforeRequests(t, test.want, test.args...)
})
}
}
func TestRepositoryOwnerArguments(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch {
case r.URL.Path == "/api/v1/orgs/acme":
_, _ = w.Write([]byte(`{"id":3,"name":"acme"}`))
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`[]`))
default:
_, _ = w.Write([]byte(`{"full_name":"acme/tool"}`))
}
}))
defer server.Close()
for _, test := range []struct {
args []string
want string
}{
{[]string{"repo", "list", "acme"}, "GET /api/v1/orgs/acme/repos"},
{[]string{"repo", "create", "acme/tool"}, "POST /api/v1/orgs/acme/repos"},
{[]string{"repo", "create", "alice/tool"}, "POST /api/v1/user/repos"},
} {
requests = nil
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, append(test.args, "--no-input", "--json")))
require.Equal(t, test.want, requests[len(requests)-1], test.args)
}
}
+16 -3
View File
@@ -4,6 +4,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -32,6 +33,15 @@ func (a *application) repoCreateCommand() *cobra.Command {
} }
opts.Name = args[0] opts.Name = args[0]
} }
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo create " + owner + "/" + cmp.Or(opts.Name, "NAME") }); err != nil {
return err
}
if owner, name, ok := strings.Cut(opts.Name, "/"); ok {
if cmd.Flags().Changed("org") {
return clierr.Wrap(clierr.Usage, errors.New("supply the owner as OWNER/NAME or --org, not both"))
}
org, opts.Name = owner, name
}
if cmd.Flags().Changed("team") && (org == "" || strings.TrimSpace(team) == "") { if cmd.Flags().Changed("team") && (org == "" || strings.TrimSpace(team) == "") {
return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name")) return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name"))
} }
@@ -66,9 +76,6 @@ func (a *application) repoCreateCommand() *cobra.Command {
} }
selected := *a selected := *a
if source != "" { if source != "" {
if a.options.repo != "" {
return clierr.Wrap(clierr.Usage, errors.New("--template and --repo cannot both select a source repository"))
}
for _, flag := range []string{"auto-init", "label-template", "gitignores", "license", "readme", "as-template", "trust-model", "object-format"} { for _, flag := range []string{"auto-init", "label-template", "gitignores", "license", "readme", "as-template", "trust-model", "object-format"} {
if cmd.Flags().Changed(flag) { if cmd.Flags().Changed(flag) {
return clierr.Wrap(clierr.Usage, errors.New("repository initialization options cannot be combined with --template")) return clierr.Wrap(clierr.Usage, errors.New("repository initialization options cannot be combined with --template"))
@@ -86,6 +93,12 @@ func (a *application) repoCreateCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if !cmd.Flags().Changed("org") && strings.EqualFold(org, target.User) {
if team != "" {
return clierr.Wrap(clierr.Usage, errors.New("--team requires a repository owned by an organization"))
}
org = ""
}
if prompted { if prompted {
if err := a.confirm(cmd, "Create repository "+opts.Name+" on "+target.Host); err != nil { if err := a.confirm(cmd, "Create repository "+opts.Name+" on "+target.Host); err != nil {
return err return err
+3
View File
@@ -26,6 +26,9 @@ func (a *application) repoMigrateCommand() *cobra.Command {
var opts services.RepositoryMigrationOptions var opts services.RepositoryMigrationOptions
var passwordFile, tokenFile, serverPath string var passwordFile, tokenFile, serverPath string
command := &cobra.Command{Use: "migrate [clone-url]", Short: "Import a remote repository through Gitea", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { command := &cobra.Command{Use: "migrate [clone-url]", Short: "Import a remote repository through Gitea", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo migrate URL --owner " + owner }); err != nil {
return err
}
if len(args) > 0 { if len(args) > 0 {
if cmd.Flags().Changed("clone-url") { if cmd.Flags().Changed("clone-url") {
return clierr.Wrap(clierr.Usage, errors.New("supply clone URL as an argument or --clone-url, not both")) return clierr.Wrap(clierr.Usage, errors.New("supply clone URL as an argument or --clone-url, not both"))
+3
View File
@@ -27,6 +27,9 @@ func (a *application) searchCommand() *cobra.Command {
var opts services.RepositorySearchOptions var opts services.RepositorySearchOptions
var private, archived bool var private, archived bool
command := &cobra.Command{Use: "repo [query]...", Short: "Search repository names, descriptions or topics", RunE: func(cmd *cobra.Command, args []string) error { command := &cobra.Command{Use: "repo [query]...", Short: "Search repository names, descriptions or topics", RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea search repo --owner " + owner }); err != nil {
return err
}
if err := validateRepositoryKind(opts.Kind); err != nil { if err := validateRepositoryKind(opts.Kind); err != nil {
return err return err
} }
+2 -2
View File
@@ -150,7 +150,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"release", "edit", "v1"}, "supply release fields to edit"}, {[]string{"release", "edit", "v1"}, "supply release fields to edit"},
{[]string{"release", "edit", "v1", "v2", "--tag", "v3"}, "--tag requires one release and a nonempty new tag"}, {[]string{"release", "edit", "v1", "v2", "--tag", "v3"}, "--tag requires one release and a nonempty new tag"},
{[]string{"repo", "create"}, "missing required input: supply --name"}, {[]string{"repo", "create"}, "missing required input: supply --name"},
{[]string{"repo", "create", "bad/name"}, "repository name must be nonempty, at most 100 bytes, and contain no path separators or whitespace"}, {[]string{"repo", "create", "acme/bad/name"}, "repository name must be nonempty, at most 100 bytes, and contain no path separators or whitespace"},
{[]string{"repo", "create", "name", "--name", "other"}, "supply name as an argument or --name, not both"}, {[]string{"repo", "create", "name", "--name", "other"}, "supply name as an argument or --name, not both"},
{[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"}, {[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"},
{[]string{"repo", "create", "name", "--license", "MIT"}, "initial content and default branch options require --auto-init"}, {[]string{"repo", "create", "name", "--license", "MIT"}, "initial content and default branch options require --auto-init"},
@@ -161,7 +161,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"repo", "create", "name", "--copy-content=false"}, "copy options require --template owner/repo"}, {[]string{"repo", "create", "name", "--copy-content=false"}, "copy options require --template owner/repo"},
{[]string{"repo", "create", "name", "--template="}, "--template requires owner/repo or a repository URL"}, {[]string{"repo", "create", "name", "--template="}, "--template requires owner/repo or a repository URL"},
{[]string{"repo", "create", "name", "--template", "owner/source", "--auto-init"}, "repository initialization options cannot be combined with --template"}, {[]string{"repo", "create", "name", "--template", "owner/source", "--auto-init"}, "repository initialization options cannot be combined with --template"},
{[]string{"repo", "create", "name", "--template", "owner/source", "-R", "owner/other"}, "--template and --repo cannot both select a source repository"}, {[]string{"repo", "create", "name", "--template", "owner/source", "-R", "owner/other"}, "--repo is not used by repo create; did you mean: tea repo create owner/name"},
{[]string{"repo", "edit", "--name="}, "repository name cannot be empty"}, {[]string{"repo", "edit", "--name="}, "repository name cannot be empty"},
{[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"}, {[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"},
{[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"},