diff --git a/internal/app/app.go b/internal/app/app.go index bebca241..9e848b66 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -288,6 +288,28 @@ func (a *application) confirm(cmd *cobra.Command, description string) error { return terminal.Confirm(cmd.Context(), description, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter) } +// confirmTyped asks the user to type answer before an irreversible action unless --yes was supplied. +func (a *application) confirmTyped(cmd *cobra.Command, description, answer string) error { + if a.options.yes { + return nil + } + s, err := a.deps.Settings.Load() + if err != nil { + return err + } + if !a.policy(s).CanPrompt(a.deps.Streams) { + return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes")) + } + typed, err := a.deps.Prompter.Input(cmd.Context(), description+" Type "+answer+" to confirm:") + if err != nil { + return err + } + if !strings.EqualFold(strings.TrimSpace(typed), answer) { + return clierr.Wrap(clierr.Canceled, errors.New("operation canceled")) + } + return nil +} + // require prompts for a missing required value under the prompt policy. func (a *application) require(cmd *cobra.Command, value *string, flag string) (bool, error) { s, err := a.deps.Settings.Load() diff --git a/internal/app/repo_mutations.go b/internal/app/repo_mutations.go index 9560e0a2..07ff53bf 100644 --- a/internal/app/repo_mutations.go +++ b/internal/app/repo_mutations.go @@ -164,13 +164,24 @@ func (a *application) addRepositoryMutations(group *cobra.Command) { if err != nil { return err } - if err := a.confirm(cmd, verb+" "+t.Host+"/"+t.Repo); 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 { diff --git a/internal/app/repo_mutations_test.go b/internal/app/repo_mutations_test.go new file mode 100644 index 00000000..e55c207e --- /dev/null +++ b/internal/app/repo_mutations_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dev/tea/internal/clierr" + "github.com/stretchr/testify/require" +) + +// upstreamCheckout discovers the repository from the upstream remote, as in a clone of a fork. +func upstreamCheckout(server *httptest.Server) *fakeGit { + return &fakeGit{results: map[string]string{ + "symbolic-ref --short HEAD": "main", + "config --get branch.main.remote": "upstream", + "remote get-url upstream": server.URL + "/alice/project.git", + }} +} + +func TestRepoDeleteAndArchiveRequireExplicitRepositoryWithYes(t *testing.T) { + var requests []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + for _, verb := range []string{"delete", "archive"} { + deps, _, _ := serverDeps(t, server.URL) + deps.Git = upstreamCheckout(server) + err := Execute(context.Background(), deps, []string{"repo", verb, "--yes"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err) + require.ErrorContains(t, err, "--yes requires an explicit repository, not one inferred from git remote upstream: tea repo "+verb+" alice/project --yes") + } + require.Empty(t, requests) + deps, _, _ := serverDeps(t, server.URL) + deps.Git = upstreamCheckout(server) + require.NoError(t, Execute(context.Background(), deps, []string{"repo", "delete", "alice/project", "--yes"})) + require.Equal(t, []string{"DELETE /api/v1/repos/alice/project"}, requests) +} + +func TestRepoDeleteAsksToTypeTheRepositoryName(t *testing.T) { + var requests []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + for _, test := range []struct { + answer string + deleted bool + }{{"alice/other", false}, {"", false}, {"alice/project", true}} { + requests = nil + deps, _, _ := serverDeps(t, server.URL) + deps.Git = upstreamCheckout(server) + prompt := &scriptedPrompter{inputs: map[string]string{"Delete alice/project (from git remote upstream)? This cannot be undone. Type alice/project to confirm:": test.answer}} + deps.Prompter = prompt + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + err := Execute(context.Background(), deps, []string{"repo", "delete"}) + require.Len(t, prompt.asked, 1) + require.Empty(t, prompt.confirmations) + if !test.deleted { + require.Equal(t, clierr.Canceled, clierr.ExitCode(err), "%v", err) + require.Empty(t, requests) + continue + } + require.NoError(t, err) + require.Equal(t, []string{"DELETE /api/v1/repos/alice/project"}, requests) + } +}