Files
gitea-tea/internal/app/repos.go
T
bircni 368de47c40 feat(repo): sync forks on the server, wire forks into clones, validate set-default
repo sync OWNER/REPO, run where no Git remote points to the repository,
now updates it on the server: a fork's branch fast-forwards to its
parent's through merge-upstream, and a pull mirror fetches its source.

repo fork reports an existing fork ("! bob/x already exists") instead of
a raw HTTP 409, refuses to fork a repository into its own owner, and
gains --fork-name, --clone (Git flags after --) and --remote, which adds
the fork as origin and renames the old origin to upstream.

repo clone passes Git flags after --, and takes --upstream-remote-name
and --no-upstream for forks. Clones report what they set up.

repo set-default checks that the repository exists before saving it,
prints the saved repository with --view, removes it with --unset, and
reports the change.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 23:51:47 +02:00

339 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, 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 [<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), 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)
}