mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
The application caches the settings snapshot and reloads it only after an update through the store, so output and prompt helpers no longer read and parse config.yml and hosts.yml several times per command. canPrompt and chooser replace the inline prompt-policy checks. Checks are tallied by one services.Tally, terminal.Plural replaces the two app copies, refKind.article/noun replace the comment group's own words, list commands use servicePage, and web URLs are built by repositoryURL and webURL in browser.go. Signed-off-by: bircni <bircni@icloud.com>
331 lines
12 KiB
Go
331 lines
12 KiB
Go
// 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 [<repository>] 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 [<repository>]",
|
|
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 [<owner>]",
|
|
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 [<repository>]",
|
|
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
|
|
}
|