mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
repo unarchive restores an archived repository, and repo rename renames one, moving the Git remote or the saved default repository that selected it to the new name. The archived-repository hint now points to tea repo unarchive. repo archive asks "Archive alice/x? It becomes read-only." and warns "! alice/x is already archived" without a request when there is nothing to do. Archive, unarchive, rename and delete confirm with a ✓ line on a terminal, and --json returns the repository. Signed-off-by: bircni <bircni@icloud.com>
481 lines
18 KiB
Go
481 lines
18 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
func repositoryRecord(item services.Repository) terminal.Record {
|
|
return terminal.Record{Heading: []terminal.Cell{{Text: item.FullName, Bold: true}}, Lines: []string{item.URL}}
|
|
}
|
|
|
|
func cloneURLForProtocol(cloneURL, sshURL, protocol string) (remote string, ssh bool, err error) {
|
|
remote = cloneURL
|
|
if protocol == "ssh" {
|
|
remote = sshURL
|
|
}
|
|
if remote == "" {
|
|
return "", false, errors.New("Gitea did not return a clone URL")
|
|
}
|
|
ssh = settings.RemoteIsSSH(remote)
|
|
return remote, ssh, nil
|
|
}
|
|
|
|
// apiSSHHost trusts the SSH authority of the API's ssh_url when the instance has no ssh_host,
|
|
// so servers whose SSH host or port differ from the web host clone without extra configuration.
|
|
func apiSSHHost(host settings.Host, sshURL string) settings.Host {
|
|
if host.SSHHost != "" || !settings.RemoteIsSSH(sshURL) {
|
|
return host
|
|
}
|
|
if u, err := settings.ParseRemote(sshURL); err == nil {
|
|
if _, err := settings.SSHEndpoint(u.Host); err == nil {
|
|
host.SSHHost = u.Host
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
// mirrorSourceURL returns a mirror's credential-free network source URL, or "" when there is none to add.
|
|
func mirrorSourceURL(item services.Repository) string {
|
|
if !item.Mirror || item.OriginalURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(item.OriginalURL)
|
|
if err != nil || u.Host == "" || strings.ContainsAny(item.OriginalURL, " \t\r\n\\") {
|
|
return ""
|
|
}
|
|
switch u.Scheme {
|
|
case "https", "http", "git":
|
|
if u.User != nil {
|
|
return ""
|
|
}
|
|
case "ssh":
|
|
if _, password := u.User.Password(); password {
|
|
return ""
|
|
}
|
|
default:
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func (a *application) addRepositoryMutations(group *cobra.Command) {
|
|
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand())
|
|
var forkOrg string
|
|
fork := &cobra.Command{Use: "fork [repository]", Short: "Fork the selected repository", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := a.repositoryArgument(args); err != nil {
|
|
return err
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item, err := (services.Repositories{Client: t.client}).Fork(cmd.Context(), t.Repo, forkOrg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeRecord(a, cmd, item, repositoryRecord)
|
|
}}
|
|
fork.Flags().StringVar(&forkOrg, "org", "", "Fork under an organization")
|
|
group.AddCommand(fork)
|
|
var description, repoName, website, defaultBranch string
|
|
var private, template, repoArchived, actionsEnabled bool
|
|
edit := &cobra.Command{Use: "edit [repository]", Short: "Edit repository settings", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := a.repositoryArgument(args); err != nil {
|
|
return err
|
|
}
|
|
options := services.RepositoryEdit{}
|
|
if cmd.Flags().Changed("name") {
|
|
if strings.TrimSpace(repoName) == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("repository name cannot be empty"))
|
|
}
|
|
options.Name = &repoName
|
|
}
|
|
if cmd.Flags().Changed("website") {
|
|
options.Website = &website
|
|
}
|
|
if cmd.Flags().Changed("default-branch") {
|
|
if strings.TrimSpace(defaultBranch) == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("default branch cannot be empty"))
|
|
}
|
|
options.DefaultBranch = &defaultBranch
|
|
}
|
|
if cmd.Flags().Changed("archived") {
|
|
options.Archived = &repoArchived
|
|
}
|
|
|
|
if cmd.Flags().Changed("description") {
|
|
options.Description = &description
|
|
}
|
|
if cmd.Flags().Changed("private") {
|
|
options.Private = &private
|
|
}
|
|
if cmd.Flags().Changed("as-template") {
|
|
options.Template = &template
|
|
}
|
|
if cmd.Flags().Changed("actions") {
|
|
options.ActionsEnabled = &actionsEnabled
|
|
}
|
|
if options == (services.RepositoryEdit{}) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea repo edit --help"))
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if repoArchived {
|
|
if err := a.confirm(cmd, "Archive repository "+t.Repo); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
item, err := (services.Repositories{Client: t.client}).Edit(cmd.Context(), t.Repo, options)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeRecord(a, cmd, item, repositoryRecord)
|
|
}}
|
|
edit.Flags().StringVar(&repoName, "name", "", "Rename the repository")
|
|
edit.Flags().StringVar(&website, "website", "", "Repository website; empty clears")
|
|
edit.Flags().StringVar(&defaultBranch, "default-branch", "", "Default branch name")
|
|
edit.Flags().BoolVar(&repoArchived, "archived", false, "Archive repository; =false restores it")
|
|
edit.Flags().StringVar(&description, "description", "", "Repository description")
|
|
edit.Flags().BoolVar(&private, "private", false, "Make repository private (use --private=false to make public)")
|
|
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)")
|
|
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)")
|
|
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
|
|
var depth int
|
|
clone := &cobra.Command{Use: "clone <repository> [directory]", Short: "Clone a repository using the configured Git protocol", Args: usageArgs(cobra.RangeArgs(1, 2)), RunE: func(cmd *cobra.Command, args []string) error {
|
|
if depth < 0 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
|
|
}
|
|
if a.options.repo != "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --repo"))
|
|
}
|
|
a.options.repo = args[0]
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
directory := ""
|
|
if len(args) == 2 {
|
|
directory = args[1]
|
|
}
|
|
if err := a.cloneRepository(cmd, t, item, directory, depth); err != nil {
|
|
return err
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, item)
|
|
}
|
|
return nil
|
|
}}
|
|
clone.Flags().IntVarP(&depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
|
|
group.AddCommand(clone)
|
|
}
|
|
|
|
// cloneRepository clones item with the configured Git protocol and adds fork and mirror remotes.
|
|
// An empty directory lets Git choose it from the repository name.
|
|
func (a *application) cloneRepository(cmd *cobra.Command, t target, item services.Repository, directory string, depth int) error {
|
|
protocol := t.gitProtocol
|
|
host := t.host
|
|
if protocol == "ssh" {
|
|
host = apiSSHHost(host, item.SSHURL)
|
|
}
|
|
hosts := map[string]settings.Host{t.Host: host}
|
|
remote, remoteIsSSH, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, protocol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cloneHost, cloneRepo, err := settings.RepositoryURL(remote, hosts)
|
|
if err != nil || cloneHost != t.Host || !strings.EqualFold(cloneRepo, t.Repo) || (protocol == "ssh") != remoteIsSSH {
|
|
return errors.New("Gitea clone URL does not match the selected instance and repository; check the server root URL and configured ssh_host")
|
|
}
|
|
gitArgs := []string{"clone"}
|
|
if depth > 0 {
|
|
gitArgs = append(gitArgs, "--depth", fmt.Sprint(depth))
|
|
}
|
|
gitArgs = append(gitArgs, "--", remote)
|
|
if directory != "" {
|
|
gitArgs = append(gitArgs, directory)
|
|
} else if directory = filepath.Base(strings.TrimSuffix(item.Name, ".git")); directory == "" || directory == "." || directory == "/" {
|
|
directory = item.Name
|
|
}
|
|
credentialArgs, err := a.gitTransportArguments(t, remoteIsSSH)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gitArgs = append(credentialArgs, gitArgs...)
|
|
if _, err := a.git(cmd.Context(), gitArgs...); err != nil {
|
|
return err
|
|
}
|
|
clone := directory
|
|
if !filepath.IsAbs(clone) {
|
|
clone = filepath.Join(a.options.cwd, clone)
|
|
}
|
|
if item.Fork && item.Parent != nil {
|
|
upstream, upstreamSSH, err := cloneURLForProtocol(item.Parent.CloneURL, item.Parent.SSHURL, protocol)
|
|
if err != nil {
|
|
return errors.New("cloned the fork, but Gitea did not return a parent clone URL")
|
|
}
|
|
upstreamHost, upstreamRepo, err := settings.RepositoryURL(upstream, hosts)
|
|
if err != nil || upstreamHost != t.Host || !strings.EqualFold(upstreamRepo, item.Parent.FullName) || (protocol == "ssh") != upstreamSSH {
|
|
return errors.New("cloned the fork, but its parent clone URL does not match the selected instance")
|
|
}
|
|
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "upstream", upstream); err != nil {
|
|
return fmt.Errorf("cloned the fork, but could not add the upstream remote: %w", err)
|
|
}
|
|
// The default branch pulls from upstream, but pushes stay on the fork.
|
|
if branch := item.Parent.DefaultBranch; branch != "" {
|
|
for _, setting := range [][2]string{{"remote", "upstream"}, {"merge", "refs/heads/" + branch}, {"pushRemote", "origin"}} {
|
|
if _, err := a.gitIn(cmd.Context(), clone, "config", "branch."+branch+"."+setting[0], setting[1]); err != nil {
|
|
return fmt.Errorf("cloned the fork and added upstream, but could not configure branch tracking: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The remote is named source because repository discovery prefers a remote named upstream.
|
|
if source := mirrorSourceURL(item); source != "" {
|
|
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "source", source); err != nil {
|
|
return fmt.Errorf("cloned the mirror, but could not add the source remote: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// inferredRepository words where a repository that no argument or --repo named came from, as in
|
|
// " (from git remote upstream)", for confirmations. Irreversible commands refuse --yes for it.
|
|
func (a *application) inferredRepository(t target, verb string, refuseYes bool) (string, error) {
|
|
if t.RepoSource == "--repo" {
|
|
return "", nil
|
|
}
|
|
source := describeRepoSource(t.RepoSource)
|
|
if refuseYes && a.options.yes {
|
|
return "", clierr.Wrap(clierr.Usage, fmt.Errorf("--yes requires an explicit repository, not one inferred from %s: tea repo %s %s --yes", source, verb, t.Repo))
|
|
}
|
|
return " (from " + source + ")", nil
|
|
}
|
|
|
|
// describeRepoSource words a repository source for people: git remote upstream, TEA_REPO, ...
|
|
func describeRepoSource(source string) string {
|
|
return strings.Replace(source, "remote ", "git remote ", 1)
|
|
}
|
|
|
|
// repoArchiveCommand builds repo archive, or repo unarchive.
|
|
func (a *application) repoArchiveCommand(archive bool) *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "archive [<repository>]",
|
|
Short: "Archive a repository",
|
|
Long: `Archive a repository. An archived repository is read-only: nobody can push to it or change its
|
|
issues, pull requests, releases or wiki until it is unarchived with tea repo unarchive.`,
|
|
Example: `# Archive a repository
|
|
$ tea repo archive alice/old-project
|
|
|
|
# Archive without a prompt; scripts must name the repository
|
|
$ tea repo archive alice/old-project --yes`,
|
|
Annotations: map[string]string{"tea.arguments": repositoryArguments, "tea.confirm": "true"},
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
}
|
|
if !archive {
|
|
command.Use, command.Short = "unarchive [<repository>]", "Unarchive a repository"
|
|
command.Long = "Unarchive an archived repository, so that it accepts pushes and changes again."
|
|
command.Example = "$ tea repo unarchive alice/old-project"
|
|
delete(command.Annotations, "tea.confirm")
|
|
}
|
|
command.RunE = func(cmd *cobra.Command, args []string) error {
|
|
if err := a.repositoryArgument(args); err != nil {
|
|
return err
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inferred, err := a.inferredRepository(t, "archive", archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Repositories{Client: t.client}
|
|
item, err := service.View(cmd.Context(), t.Repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.Archived == archive {
|
|
if archive {
|
|
a.warn(cmd, "%s is already archived", item.FullName)
|
|
} else {
|
|
a.warn(cmd, "%s is not archived", item.FullName)
|
|
}
|
|
return a.writeMutationJSON(cmd, item)
|
|
}
|
|
if archive {
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Archive " + t.Repo + inferred + "? It becomes read-only.", NonTTY: "archive " + t.Repo}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
item, err = service.Edit(cmd.Context(), t.Repo, services.RepositoryEdit{Archived: &archive})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if archive {
|
|
a.done(cmd, "Archived repository %s", item.FullName)
|
|
} else {
|
|
a.done(cmd, "Unarchived repository %s", item.FullName)
|
|
}
|
|
return a.writeMutationJSON(cmd, item)
|
|
}
|
|
command.ValidArgsFunction = a.completeRepos()
|
|
return command
|
|
}
|
|
|
|
// writeMutationJSON writes the result of a mutation with --json; people get done lines instead.
|
|
func (a *application) writeMutationJSON(cmd *cobra.Command, item any) error {
|
|
if a.options.json == "" {
|
|
return nil
|
|
}
|
|
return a.writeJSON(cmd, item)
|
|
}
|
|
|
|
func (a *application) repoRenameCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "rename <new-name>",
|
|
Short: "Rename a repository",
|
|
Long: `Rename a repository; its owner stays the same. Gitea redirects the old name to the new one. When
|
|
the repository was found through a Git remote of the current clone, or through tea repo set-default,
|
|
that remote or setting moves to the new name.`,
|
|
Example: `# Rename the repository of the current clone
|
|
$ tea repo rename new-name
|
|
|
|
# Rename another repository
|
|
$ tea repo rename new-name -R alice/old-name`,
|
|
Annotations: map[string]string{"tea.confirm": "true"},
|
|
Args: argsExact(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
name := strings.TrimSpace(args[0])
|
|
if err := validateRepositoryName(name); err != nil {
|
|
return err
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner, current, _ := strings.Cut(t.Repo, "/")
|
|
if current == name {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is already named %s", t.Repo, name))
|
|
}
|
|
inferred, err := a.inferredRepository(t, "rename", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inferred != "" {
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Rename " + t.Repo + inferred + " to " + owner + "/" + name + "?", DefaultYes: true, NonTTY: "rename " + t.Repo}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
item, err := (services.Repositories{Client: t.client}).Edit(cmd.Context(), t.Repo, services.RepositoryEdit{Name: &name})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Renamed repository %s to %s", t.Repo, item.FullName)
|
|
if err := a.followRename(cmd, t, current, item); err != nil {
|
|
return err
|
|
}
|
|
return a.writeMutationJSON(cmd, item)
|
|
},
|
|
}
|
|
return command
|
|
}
|
|
|
|
// followRename points the Git remote or the saved default repository that selected a renamed
|
|
// repository, previously named old, at its new name.
|
|
func (a *application) followRename(cmd *cobra.Command, t target, old string, item services.Repository) error {
|
|
if remote, ok := strings.CutPrefix(t.RepoSource, "remote "); ok {
|
|
raw, err := a.git(cmd.Context(), "remote", "get-url", remote)
|
|
if err != nil {
|
|
return fmt.Errorf("renamed %s, but could not read Git remote %s: %w", item.FullName, remote, err)
|
|
}
|
|
renamed, ok := renamedURL(raw, old, item.Name)
|
|
if !ok {
|
|
a.warn(cmd, "Git remote %s still points to %s; update it with git remote set-url", remote, t.Repo)
|
|
return nil
|
|
}
|
|
if _, err := a.git(cmd.Context(), "remote", "set-url", remote, renamed); err != nil {
|
|
return fmt.Errorf("renamed %s, but could not update Git remote %s: %w", item.FullName, remote, err)
|
|
}
|
|
a.done(cmd, "Updated Git remote %s to %s", remote, item.FullName)
|
|
}
|
|
if t.RepoSource == "git config tea.repository" {
|
|
if _, err := a.git(cmd.Context(), "config", "--local", "tea.repository", t.Host+"/"+item.FullName); err != nil {
|
|
return fmt.Errorf("renamed %s, but could not update the default repository: %w", item.FullName, err)
|
|
}
|
|
a.done(cmd, "Set %s as the default repository for this clone", item.FullName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// renamedURL replaces the repository name old at the end of a remote URL with name, keeping the
|
|
// rest of the URL, such as an SSH host alias, and its .git suffix.
|
|
func renamedURL(raw, old, name string) (string, bool) {
|
|
base, suffix := strings.TrimSuffix(raw, "/"), ""
|
|
if trimmed, ok := strings.CutSuffix(base, ".git"); ok {
|
|
base, suffix = trimmed, ".git"
|
|
}
|
|
if len(base) <= len(old) || !strings.EqualFold(base[len(base)-len(old):], old) || !strings.ContainsRune("/:", rune(base[len(base)-len(old)-1])) {
|
|
return "", false
|
|
}
|
|
return base[:len(base)-len(old)] + name + suffix, true
|
|
}
|
|
|
|
func (a *application) repoDeleteCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "delete [<repository>]",
|
|
Short: "Delete a repository",
|
|
Long: `Delete a repository with its issues, pull requests, releases and wiki. This cannot be undone.
|
|
|
|
Type the repository's name to confirm. With --yes, name the repository explicitly.`,
|
|
Example: `# Delete a repository after typing its name
|
|
$ tea repo delete alice/old-project
|
|
|
|
# Delete without a prompt
|
|
$ tea repo delete alice/old-project --yes`,
|
|
Annotations: map[string]string{"tea.arguments": repositoryArguments, "tea.confirm": "true", "tea.text": "repository deletion does not return a resource"},
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := a.repositoryArgument(args); err != nil {
|
|
return err
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inferred, err := a.inferredRepository(t, "delete", true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + t.Repo + inferred + "? This cannot be undone.", Typed: t.Repo, NonTTY: "delete " + t.Repo}); err != nil {
|
|
return err
|
|
}
|
|
if err := (services.Repositories{Client: t.client}).Delete(cmd.Context(), t.Repo); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Deleted repository %s", t.Repo)
|
|
return nil
|
|
},
|
|
}
|
|
command.ValidArgsFunction = a.completeRepos()
|
|
return command
|
|
}
|