From 7cd13ef64ee4b63d1dfc738dea2ccc01ef5110f3 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:18:15 +0200 Subject: [PATCH] feat(repo): add repo unarchive and repo rename, and report archive and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/app/app_test.go | 2 - internal/app/errors.go | 2 +- internal/app/errors_test.go | 2 +- internal/app/repo_mutations.go | 263 +++++++++++++++++++---- internal/app/repo_mutations_test.go | 98 +++++++++ internal/app/repository_argument_test.go | 6 +- 6 files changed, 327 insertions(+), 46 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f006a166..84c02158 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 diff --git a/internal/app/errors.go b/internal/app/errors.go index 01ac67e1..aac0f4c0 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -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) diff --git a/internal/app/errors_test.go b/internal/app/errors_test.go index 0f0728f4..09eea68d 100644 --- a/internal/app/errors_test.go +++ b/internal/app/errors_test.go @@ -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)"}, diff --git a/internal/app/repo_mutations.go b/internal/app/repo_mutations.go index 28f9b828..59ce7e75 100644 --- a/internal/app/repo_mutations.go +++ b/internal/app/repo_mutations.go @@ -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 [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 []", + 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 []", "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 ", + 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 []", + 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 +} diff --git a/internal/app/repo_mutations_test.go b/internal/app/repo_mutations_test.go index e55c207e..d848bc64 100644 --- a/internal/app/repo_mutations_test.go +++ b/internal/app/repo_mutations_test.go @@ -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()) +} diff --git a/internal/app/repository_argument_test.go b/internal/app/repository_argument_test.go index f75d1728..002a2e90 100644 --- a/internal/app/repository_argument_test.go +++ b/internal/app/repository_argument_test.go @@ -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()) }) }