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>
This commit is contained in:
bircni committed 2026-10-11 23:51:45 +02:00
1 parent eedbe9893d
commit 7cd13ef64e
6 files changed
+327 -46

No files matched your search

-2
View File
@@ -505,10 +505,8 @@ const (
release list
release publish
release view
repo archive
repo clone
repo create
repo delete
repo edit
repo fork
repo migrate
+1 -1
View File
@@ -167,7 +167,7 @@ func conflictHint(ctx context.Context, apiErr *transport.APIError, conflict *ser
switch conflict.Reason {
case services.ReasonArchived:
if conflict.Scope != "" {
return "unarchive it with: tea repo edit " + conflict.Scope + " --archived=false"
return "unarchive it with: tea repo unarchive " + conflict.Scope
}
case services.ReasonExists:
return existsHint(ctx, apiErr, conflict)
+1 -1
View File
@@ -61,7 +61,7 @@ func TestExplainNamesResourcesAndTellsWhyRepositoriesFail(t *testing.T) {
{"issue as pull request", []string{"pr", "view", "1", "-R", "alice/project"}, "#1 in alice/project is an issue, not a pull request; view it with: tea issue view 1"},
{"missing pull request", []string{"pr", "view", "999", "-R", "alice/project"}, "pull request #999 not found in alice/project"},
{"empty repository", []string{"pr", "list", "-R", "alice/empty"}, "alice/empty is empty; it has no pull requests"},
{"archived repository", []string{"issue", "create", "-R", "alice/old", "--title", "x", "--body", "y"}, "alice/old is archived (read-only); unarchive it with: tea repo edit alice/old --archived=false"},
{"archived repository", []string{"issue", "create", "-R", "alice/old", "--title", "x", "--body", "y"}, "alice/old is archived (read-only); unarchive it with: tea repo unarchive alice/old"},
{"existing pull request", []string{"pr", "create", "-R", "alice/project", "--head", "feat", "--base", "main", "--title", "x", "--body", "y"}, "pull request for feat into main already exists in alice/project; see https://example.org/alice/project/pulls/3"},
{"context added by the command", []string{"pr", "merge", "3", "-R", "alice/project", "--yes"}, "pull request #3 is a draft; mark it ready for review first: Gitea API returned HTTP 405 for POST /api/v1/repos/alice/project/pulls/3/merge: Work in progress PRs cannot be merged"},
{"raw api response", []string{"api", "users/nobody"}, "not found (HTTP 404 GET /api/v1/users/nobody)"},
+223 -40
View File
@@ -154,46 +154,7 @@ func (a *application) addRepositoryMutations(group *cobra.Command) {
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)
for _, verb := range []string{"archive", "delete"} {
command := &cobra.Command{Use: verb + " [repository]", Short: titleCase(verb) + " 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
}
inferred := ""
if t.RepoSource != "--repo" {
source := strings.Replace(t.RepoSource, "remote ", "git remote ", 1)
if 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))
}
inferred = " (from " + source + ")"
}
service := services.Repositories{Client: t.client}
if verb == "delete" {
if err := a.confirmTyped(cmd, "Delete "+t.Repo+inferred+"? This cannot be undone.", t.Repo); err != nil {
return err
}
return service.Delete(cmd.Context(), t.Repo)
}
if err := a.confirm(cmd, "Archive "+t.Repo+inferred+"?"); err != nil {
return err
}
archived := true
item, err := service.Edit(cmd.Context(), t.Repo, services.RepositoryEdit{Archived: &archived})
if err != nil {
return err
}
return writeRecord(a, cmd, item, repositoryRecord)
}}
if verb == "delete" {
command.Annotations = textOutput("repository deletion does not return a resource")
}
group.AddCommand(command)
}
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 {
@@ -295,3 +256,225 @@ func (a *application) cloneRepository(cmd *cobra.Command, t target, item service
}
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
}
+98
View File
@@ -5,6 +5,7 @@ package app
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -72,3 +73,100 @@ func TestRepoDeleteAsksToTypeTheRepositoryName(t *testing.T) {
require.Equal(t, []string{"DELETE /api/v1/repos/alice/project"}, requests)
}
}
func TestRepoArchiveStates(t *testing.T) {
for _, test := range []struct {
name string
args []string
archived bool
requests []string
feedback string
}{
{"archive", []string{"repo", "archive", "alice/x", "--yes"}, false, []string{"GET", "PATCH"}, "✓ Archived repository alice/x\n"},
{"already archived", []string{"repo", "archive", "alice/x", "--yes"}, true, []string{"GET"}, "! alice/x is already archived\n"},
{"unarchive", []string{"repo", "unarchive", "alice/x"}, true, []string{"GET", "PATCH"}, "✓ Unarchived repository alice/x\n"},
{"not archived", []string{"repo", "unarchive", "alice/x"}, false, []string{"GET"}, "! alice/x is not archived\n"},
} {
t.Run(test.name, func(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/x", r.URL.Path)
requests = append(requests, r.Method)
archived := test.archived
if r.Method == http.MethodPatch {
requirePayload(t, fmt.Sprintf(`{"archived":%t}`, !archived), r.Body)
archived = !archived
}
_, _ = fmt.Fprintf(w, `{"full_name":"alice/x","archived":%t}`, archived)
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, test.args))
require.Equal(t, test.requests, requests)
require.Empty(t, out.String())
require.Equal(t, test.feedback, diagnostics.String())
})
}
}
func TestRepoArchiveAsksWithTheConsequence(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
_, _ = w.Write([]byte(`{"full_name":"alice/project"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Git = upstreamCheckout(server)
prompt := &scriptedPrompter{}
deps.Prompter, deps.Streams.InTTY, deps.Streams.ErrTTY = prompt, true, true
err := Execute(context.Background(), deps, []string{"repo", "archive"})
require.Equal(t, clierr.Canceled, clierr.ExitCode(err), "%v", err)
require.Equal(t, []string{"Archive alice/project (from git remote upstream)? It becomes read-only."}, prompt.confirmations)
}
func TestRepoRenameFollowsTheRemote(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPatch+" /api/v1/repos/alice/project", r.Method+" "+r.URL.Path)
requirePayload(t, `{"name":"renamed"}`, r.Body)
_, _ = w.Write([]byte(`{"name":"renamed","full_name":"alice/renamed"}`))
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
runner := &fakeGit{results: map[string]string{
"symbolic-ref --short HEAD": "main",
"config --get branch.main.remote": "origin",
"remote get-url origin": server.URL + "/alice/project.git",
"remote set-url origin " + server.URL + "/alice/renamed.git": "",
}}
deps.Git = runner
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "rename", "renamed", "--yes"}))
require.Contains(t, runner.calls, "remote set-url origin "+server.URL+"/alice/renamed.git")
require.Empty(t, out.String())
require.Equal(t, "✓ Renamed repository alice/project to alice/renamed\n✓ Updated Git remote origin to alice/renamed\n", diagnostics.String())
}
func TestRenamedURL(t *testing.T) {
for _, test := range []struct{ raw, want string }{
{"https://gitea.test/alice/project.git", "https://gitea.test/alice/renamed.git"},
{"https://gitea.test/alice/project/", "https://gitea.test/alice/renamed"},
{"git@gitea-alias:alice/Project.git", "git@gitea-alias:alice/renamed.git"},
{"ssh://git@gitea.test:2222/alice/project", "ssh://git@gitea.test:2222/alice/renamed"},
{"https://gitea.test/alice/subproject.git", ""},
} {
renamed, ok := renamedURL(test.raw, "project", "renamed")
require.Equal(t, test.want != "", ok, test.raw)
require.Equal(t, test.want, renamed, test.raw)
}
}
func TestRepoDeleteReportsTheDeletion(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete+" /api/v1/repos/alice/x", r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "rm", "alice/x", "--yes"}))
require.Empty(t, out.String())
require.Equal(t, "✓ Deleted repository alice/x\n", diagnostics.String())
}
+4 -2
View File
@@ -18,8 +18,10 @@ func TestRepositoryCommandsAcceptPositionalRepository(t *testing.T) {
requests = append(requests, r.Method+" "+r.URL.Path)
if r.Method == http.MethodPatch {
requirePayload(t, `{"archived":true}`, r.Body)
_, _ = w.Write([]byte(`{"full_name":"bob/tool","archived":true}`))
return
}
_, _ = w.Write([]byte(`{"full_name":"bob/tool","archived":true}`))
_, _ = w.Write([]byte(`{"full_name":"bob/tool"}`))
}))
defer server.Close()
for _, test := range []struct {
@@ -36,7 +38,7 @@ func TestRepositoryCommandsAcceptPositionalRepository(t *testing.T) {
deps, out, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
require.NoError(t, Execute(context.Background(), deps, append(test.args, "--no-input", "--json=full_name")))
require.Equal(t, []string{test.want}, requests)
require.Equal(t, test.want, requests[len(requests)-1])
require.JSONEq(t, `{"full_name":"bob/tool"}`, out.String())
})
}