Files
gitea-tea/internal/app/repo_mutations.go
T
bircni 7cd13ef64e feat(repo): add repo unarchive and repo rename, and report archive and delete
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>
2026-10-11 23:51:45 +02:00

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
}