// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "errors" "fmt" "strings" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) // repositoryArguments documents the [] argument of repository commands. const repositoryArguments = `A repository can be supplied as NAME (one of yours), OWNER/NAME, HOST/OWNER/NAME or a URL. Without an argument, the repository of the current Git clone is used.` // repositoryArgument selects the repository from an optional positional owner/name or URL. func (a *application) repositoryArgument(args []string) error { if len(args) == 0 { return nil } if a.options.repo != "" { return clierr.Wrap(clierr.Usage, errors.New("supply the repository as an argument or --repo, not both")) } a.options.repo = args[0] 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 { group := &cobra.Command{ Use: "repo", Short: "Work with Gitea repositories", Long: `Work with Gitea repositories: list and view them, create, fork, clone and migrate them, keep local branches and forks in sync, and change their settings.`, } group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand()) group.AddCommand(&cobra.Command{Use: "set-default [repository]", Short: "Save the selected repository in local Git configuration", Annotations: textOutput("set-default configures Git"), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { if err := a.repositoryArgument(args); err != nil { return err } target, err := a.resolve(cmd.Context(), true) if err != nil { return err } _, err = a.git(cmd.Context(), "config", "--local", "tea.repository", target.Host+"/"+target.Repo) return err }}) a.addRepositoryMutations(group) return group } func (a *application) repoListCommand() *cobra.Command { var opts services.RepositoryListOptions list := &cobra.Command{ Use: "list []", Short: "List repositories", Long: `List the repositories you can access, those of a user or organization, or the repositories you starred or watch.`, Example: `# List the repositories you can access $ tea repo list # List the forks of an organization $ tea repo list acme --type fork # List your starred repositories as JSON $ tea repo list --starred --json full_name,url`, 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] } scopes := 0 for _, selected := range []bool{opts.Owner != "", opts.Starred, opts.Watched} { if selected { scopes++ } } if scopes > 1 { return clierr.Wrap(clierr.Usage, errors.New("an owner, --starred and --watched select different collections; pass one")) } target, err := a.resolve(cmd.Context(), false) if err != nil { return err } result, err := (services.Repositories{Client: target.client}).ListPage(cmd.Context(), opts) if err != nil { return err } collection := repositoryCollection(opts) spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: collection, Empty: "no " + collection} return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted}) }, } pageFlags(list, &opts.Limit, &opts.Page) list.Flags().StringVar(&opts.Owner, "owner", "", "List the repositories of this user or organization") _ = list.Flags().MarkHidden("owner") enumFlag(list, &opts.Kind, "type", "", "", "Only list repositories of this kind", "fork", "source", "mirror") list.Flags().BoolVar(&opts.Starred, "starred", false, "List your starred repositories") list.Flags().BoolVar(&opts.Watched, "watched", false, "List the repositories you watch") _ = list.RegisterFlagCompletionFunc("owner", a.completeOrgs()) list.ValidArgsFunction = a.completeOrgs() return list } // repositoryCollection names the repositories that repo list selects, as in "forks owned by acme". func repositoryCollection(opts services.RepositoryListOptions) string { kinds := map[string]string{"fork": "forks", "mirror": "mirrors", "source": "source repositories"} noun, ok := kinds[opts.Kind] if !ok { noun = "repositories" } switch { case opts.Owner != "": return noun + " owned by " + opts.Owner case opts.Starred: return "starred " + noun case opts.Watched: return "watched " + noun } return noun + " you can access" } // repositoryColumns head the rows of repositoryRow in repo list and search repo. var repositoryColumns = []terminal.Column{{Header: "Name"}, {Header: "Description", Flex: true}, {Header: "Info"}, {Header: "Updated"}, {Header: "URL", PipedOnly: true}} func repositoryRow(item services.Repository) []terminal.Cell { return []terminal.Cell{{Text: item.FullName, Link: item.URL}, {Text: item.Description}, {Text: strings.Join(repositoryInfo(item), ", ")}, {Time: terminal.ParseTime(item.UpdatedAt)}, {Text: item.URL}} } // repositoryInfo describes the visibility and the kind of a repository: public or private, then // fork, mirror, template, archived or empty. func repositoryInfo(item services.Repository) []string { info := []string{"public"} if item.Private { info[0] = "private" } for _, kind := range []struct { is bool name string }{{item.Fork, "fork"}, {item.Mirror, "mirror"}, {item.Template, "template"}, {item.Archived, "archived"}, {item.Empty, "empty"}} { if kind.is { info = append(info, kind.name) } } return info } func (a *application) repoViewCommand() *cobra.Command { var web bool var branch string view := &cobra.Command{ Use: "view []", Short: "View a repository", Long: `View a repository: its description, state, counts and clone URLs, followed by its README, which is rendered on a terminal.`, Example: `# View the repository of the current clone $ tea repo view # View the README of another branch $ tea repo view alice/project --branch develop # Open the repository in the browser $ tea repo view alice/project --web`, Annotations: map[string]string{"tea.arguments": repositoryArguments}, Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { if err := a.repositoryArgument(args); err != nil { return err } target, err := a.resolve(cmd.Context(), true) if err != nil { return err } service := services.Repositories{Client: target.client} item, err := service.View(cmd.Context(), target.Repo) if err != nil { return err } if web { return a.openBrowser(cmd, item.URL) } readme := "" if a.options.json == "" && !item.Empty { if _, readme, err = service.Readme(cmd.Context(), target.Repo, branch); err != nil { return err } } return writeView(a, cmd, item, func(item services.Repository) terminal.View { return a.repositoryView(item, readme) }) }, } view.Flags().BoolVarP(&web, "web", "w", false, "Open the repository in the browser") view.Flags().StringVarP(&branch, "branch", "b", "", "Show the README of this branch (default: the default branch)") _ = view.RegisterFlagCompletionFunc("branch", a.completeBranches()) view.ValidArgsFunction = a.completeRepos() return view } // repositoryView shows a repository with its README as the body. func (a *application) repositoryView(item services.Repository, readme string) terminal.View { badges := []terminal.Cell{{Text: "public", Color: "green"}} if item.Private { badges[0] = terminal.Cell{Text: "private", Color: "yellow"} } for _, kind := range repositoryInfo(item)[1:] { badge := terminal.Cell{Text: kind, Muted: true} if kind == "archived" { badge = terminal.Cell{Text: "archived (read-only)", Color: "yellow"} } badges = append(badges, badge) } meta := []string{ fmt.Sprintf("★ %d", item.Stars), countNoun(item.Forks, "fork"), countNoun(item.OpenIssues, "open issue"), countNoun(item.OpenPulls, "open pull request"), } if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() { meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated)) } view := terminal.View{ Title: item.FullName, Badges: badges, Meta: strings.Join(meta, " · "), Body: readme, Kind: "repository", URL: item.URL, Fields: []terminal.Field{ {Label: "Description", Value: item.Description}, {Label: "Forked from", Value: parentName(item)}, {Label: "Mirror of", Value: mirrorSourceURL(item)}, {Label: "Default branch", Value: item.DefaultBranch}, {Label: "Website", Value: item.Website}, {Label: "Topics", Value: strings.Join(item.Topics, ", ")}, {Label: "Clone", Value: item.CloneURL}, {Label: "SSH", Value: item.SSHURL}, }, } if a.deps.Streams.OutTTY && strings.TrimSpace(readme) == "" { view.Body = "_This repository has no README._" if item.Empty { view.Body = "_This repository is empty._" } } return view } func parentName(item services.Repository) string { if item.Parent == nil { return "" } return item.Parent.FullName } // countNoun counts n of a noun, as in "1 fork" or "3 forks". func countNoun(n int, noun string) string { if n == 1 { return "1 " + noun } return fmt.Sprintf("%d %ss", n, noun) }