// 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(), a.repoSetDefaultCommand()) a.addRepositoryMutations(group) return group } func (a *application) repoSetDefaultCommand() *cobra.Command { var view, unset bool command := &cobra.Command{ Use: "set-default []", Short: "Set the default repository for this clone", Long: `Set the repository that tea commands use in the current Git clone, instead of the one found through its Git remotes. It is saved as tea.repository in the clone's Git configuration. Without an argument, the repository that tea selects now is saved.`, Example: `# Make tea commands in this clone use the fork's parent $ tea repo set-default alice/project # Show the saved repository $ tea repo set-default --view # Go back to the repository of the Git remotes $ tea repo set-default --unset`, Annotations: map[string]string{"tea.arguments": repositoryArguments, "tea.text": "set-default configures Git"}, Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { switch { case view && unset: return clierr.Wrap(clierr.Usage, errors.New("--view and --unset cannot be combined")) case (view || unset) && len(args) > 0: return clierr.Wrap(clierr.Usage, errors.New("--view and --unset take no repository")) } ctx := cmd.Context() if inside, _ := a.gitProbe().Output(ctx, a.options.cwd, false, "rev-parse", "--is-inside-work-tree"); inside != "true" { return clierr.Wrap(clierr.Usage, errors.New("set-default must run inside a Git clone")) } saved, _ := a.gitProbe().Output(ctx, a.options.cwd, false, "config", "--local", "--get", "tea.repository") switch { case view && saved == "": _, err := fmt.Fprintln(cmd.ErrOrStderr(), "no default repository is set for this clone") return err case view: _, err := fmt.Fprintln(cmd.OutOrStdout(), terminal.SafeLine(saved)) return err case unset && saved == "": a.warn(cmd, "No default repository is set for this clone") return nil case unset: if _, err := a.git(ctx, "config", "--local", "--unset", "tea.repository"); err != nil { return err } a.done(cmd, "Removed the default repository %s from this clone", saved) return nil } if err := a.repositoryArgument(args); err != nil { return err } t, err := a.resolve(ctx, true) if err != nil { return err } item, err := (services.Repositories{Client: t.client}).View(ctx, t.Repo) if err != nil { return err } if _, err := a.git(ctx, "config", "--local", "tea.repository", t.Host+"/"+item.FullName); err != nil { return err } a.done(cmd, "Set %s as the default repository for this clone", item.FullName) return nil }, } command.Flags().BoolVarP(&view, "view", "v", false, "Print the saved default repository") command.Flags().BoolVarP(&unset, "unset", "u", false, "Remove the saved default repository") command.ValidArgsFunction = a.completeRepos() return command } 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, servicePage(result, opts.Page)) }, } 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), terminal.Plural(item.Forks, "fork"), terminal.Plural(item.OpenIssues, "open issue"), terminal.Plural(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 }