// 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 of its Git remotes (saved as tea.repository in the clone's Git configuration).`, 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.completeArgumentBranches()) view.ValidArgsFunction = a.completeRepos() return view } 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 }