diff --git a/internal/app/admin_users.go b/internal/app/admin_users.go index 9056a07e..ad3c9b22 100644 --- a/internal/app/admin_users.go +++ b/internal/app/admin_users.go @@ -21,11 +21,7 @@ func (a *application) adminCommand() *cobra.Command { group := &cobra.Command{Use: "admin", Short: "Manage instance administration", Long: "Administer the Gitea instance. These commands need an administrator account."} users := &cobra.Command{Use: "user", Short: "Manage registered accounts", Long: `List, view, create, edit and delete the accounts of the instance. These commands need an administrator account.`} - users.AddCommand(a.adminUserListCommand(), a.adminUserViewCommand()) - for _, verb := range []string{"create", "edit"} { - users.AddCommand(a.adminUserChangeCommand(verb)) - } - users.AddCommand(a.adminUserDeleteCommand()) + users.AddCommand(a.adminUserListCommand(), a.adminUserViewCommand(), a.adminUserChangeCommand("create"), a.adminUserChangeCommand("edit"), a.adminUserDeleteCommand()) group.AddCommand(users) return group } @@ -149,7 +145,6 @@ $ tea admin user view bob --json email,admin`, } } -// adminUserChangeCommand creates or edits an account. func (a *application) adminUserChangeCommand(verb string) *cobra.Command { var username, email, fullName, description, website, location, visibility, password, passwordFile string var admin, active, restricted, prohibitLogin, mustChangePassword, allowGitHook, allowImportLocal, allowCreateOrganization bool @@ -330,9 +325,8 @@ func (a *application) adminUserDeleteCommand() *cobra.Command { var purge bool cmd := &cobra.Command{ Use: "delete ", Short: "Delete an account", Args: argsExact(1), ValidArgsFunction: a.completeAdminUsers(), - Long: `Delete an account. Gitea refuses while the user owns repositories or is the last owner of an -organization: transfer or delete those first, or pass --purge to delete the account with -everything it owns. A purge cannot be undone, so a terminal asks you to type the username.`, + Long: `Delete an account. Gitea refuses while the user owns repositories or organizations, unless +--purge deletes the account with everything it owns.`, Example: `$ tea admin user delete spammer # also delete the user's repositories, packages and organization memberships $ tea admin user delete spammer --purge`, diff --git a/internal/app/branches.go b/internal/app/branches.go index 076d1a2e..b3bc15ce 100644 --- a/internal/app/branches.go +++ b/internal/app/branches.go @@ -18,23 +18,17 @@ import ( // validateBranch rejects invalid Git refs before making requests. func validateBranch(name string) error { - if name == "" || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") || strings.Contains(name, "@{") || strings.Contains(name, "//") || strings.ContainsAny(name, " ~^:?*[\\\t\r\n") { - return clierr.Wrap(clierr.Usage, errors.New("invalid branch name")) - } + invalid := name == "" || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") || strings.Contains(name, "@{") || strings.Contains(name, "//") || strings.ContainsAny(name, " ~^:?*[\\\t\r\n") || + strings.ContainsFunc(name, func(r rune) bool { return r < 32 || r == 127 }) for part := range strings.SplitSeq(name, "/") { - if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { - return clierr.Wrap(clierr.Usage, errors.New("invalid branch name")) - } + invalid = invalid || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") } - for _, r := range name { - if r < 32 || r == 127 { - return clierr.Wrap(clierr.Usage, errors.New("invalid branch name")) - } + if invalid { + return clierr.Wrap(clierr.Usage, errors.New("invalid branch name")) } return nil } -// validateBranches validates every branch name. func validateBranches(names []string) error { for _, name := range names { if err := validateBranch(name); err != nil { @@ -249,7 +243,10 @@ $ tea branch delete old-a old-b --yes`, if err != nil { return err } - subject := branchSubject(args) + subject := "branch " + args[0] + if len(args) > 1 { + subject = fmt.Sprintf("%d branches (%s)", len(args), strings.Join(args, ", ")) + } if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + target.Repo + "?"}); err != nil { return err } @@ -263,14 +260,6 @@ $ tea branch delete old-a old-b --yes`, return command } -// branchSubject names one or more branches in a prompt: "branch main" or "2 branches (a, b)". -func branchSubject(names []string) string { - if len(names) == 1 { - return "branch " + names[0] - } - return fmt.Sprintf("%d branches (%s)", len(names), strings.Join(names, ", ")) -} - func (a *application) branchProtectCommand() *cobra.Command { var rule services.Protection var pattern string @@ -278,12 +267,8 @@ func (a *application) branchProtectCommand() *cobra.Command { command := &cobra.Command{ Use: "protect ...", Short: "Protect branches", - Long: `Add a protection rule for each branch. By default nobody can push to a protected branch directly -and pull requests need one approving review to merge; --allow-push lets users or teams push, and ---required-approvals changes the review count. - -The branches must exist. --pattern protects every branch that matches a glob instead, such as -release/*, including branches created later.`, + Long: `Add a protection rule for each existing branch, or with --pattern for every branch matching a +glob. By default nobody can push directly and pull requests need one approving review.`, Example: `# Require two approvals and passing CI on main $ tea branch protect main --required-approvals 2 --status-check ci/build diff --git a/internal/app/organizations.go b/internal/app/organizations.go index d55e0175..19446e5c 100644 --- a/internal/app/organizations.go +++ b/internal/app/organizations.go @@ -19,7 +19,6 @@ import ( var visibilities = []string{"public", "limited", "private"} -// visibilityColors color the visibility of organizations and accounts. var visibilityColors = map[string]string{"public": "green", "limited": "yellow", "private": "red"} // timeField shows an API timestamp relative to now on a TTY, and as RFC3339 for scripts. @@ -38,14 +37,6 @@ func timeField(label, raw string, d terminal.Display) terminal.Field { return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)} } -// countOf words a count, such as "1 member" or "2 members". -func countOf(n int, singular, plural string) string { - if n == 1 { - return "1 " + singular - } - return strconv.Itoa(n) + " " + plural -} - func (a *application) organizationCommand() *cobra.Command { group := &cobra.Command{Use: "org", Short: "Manage organizations", Long: `Work with Gitea organizations: list the ones you belong to, view, create, edit and delete them, and manage their members. @@ -104,7 +95,7 @@ $ tea org list --all`, } func (a *application) organizationViewCommand() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "view ", Short: "View an organization", Args: argsExact(1), Example: `$ tea org view acme # print the description only @@ -131,7 +122,6 @@ $ tea org view acme --jq .description`, }) }, } - return cmd } func organizationView(org services.Organization, size services.OrganizationSize, address string) terminal.View { @@ -149,7 +139,11 @@ func organizationView(org services.Organization, size services.OrganizationSize, singular, plural string }{{"Members", size.Members, "member", "members"}, {"Teams", size.Teams, "team", "teams"}, {"Repositories", size.Repositories, "repository", "repositories"}} { if count.n != nil { - counts = append(counts, countOf(*count.n, count.singular, count.plural)) + noun := count.plural + if *count.n == 1 { + noun = count.singular + } + counts = append(counts, strconv.Itoa(*count.n)+" "+noun) view.Fields = append(view.Fields, terminal.Field{Label: count.label, Value: strconv.Itoa(*count.n), PipedOnly: true}) } } @@ -200,7 +194,6 @@ func organizationFlags(cmd *cobra.Command, create bool) func() services.Organiza } } -// organizationName rejects an empty organization argument. func organizationName(raw string) (string, error) { if name := strings.TrimSpace(raw); name != "" { return name, nil @@ -270,11 +263,7 @@ $ tea org edit acme --visibility private`, func (a *application) organizationDeleteCommand() *cobra.Command { return &cobra.Command{ Use: "delete ", Short: "Delete an organization", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(), - Long: `Delete an organization with its teams. Gitea refuses while the organization owns -repositories: transfer or delete them first. - -The deletion cannot be undone, so a terminal asks you to type the organization's name; --yes -skips the question.`, + Long: "Delete an organization with its teams. Gitea refuses while the organization owns repositories.", Example: `$ tea org delete acme-old $ tea org delete acme-old --yes`, Annotations: map[string]string{"tea.text": "organization deletion does not return a resource", "tea.confirm": "true"}, @@ -371,7 +360,6 @@ func (a *application) completeOrganizationMembers() cobra.CompletionFunc { } } -// organizationVisibilityCommand publicizes or conceals an organization membership. func (a *application) organizationVisibilityCommand(public bool) *cobra.Command { verb, alias, short, state := "conceal", "private", "Hide a membership", "concealed" if public { diff --git a/internal/app/release_assets.go b/internal/app/release_assets.go index d90ad47c..cdfc2a25 100644 --- a/internal/app/release_assets.go +++ b/internal/app/release_assets.go @@ -165,11 +165,9 @@ func (a *application) releaseDownloadCommand() *cobra.Command { command := &cobra.Command{ Use: "download [] [...]", Short: "Download release assets", - Long: `Download assets of a release, by name or ID, or those matching --pattern; without either, every -asset. --archive downloads the source code archive instead. Without a tag, the latest release is used. - -Files are written to the current directory under their names, to --dir, or to --output; an existing -file is replaced only with --clobber. --output - writes one asset to standard output.`, + Long: `Download the assets of a release (default: the latest) by name or ID, those matching --pattern, +or all of them; --archive downloads the source code instead. Existing files are replaced only with +--clobber, and --output - writes one asset to standard output.`, Example: `# Download every asset of the latest release $ tea release download @@ -285,7 +283,6 @@ func selectAssets(release services.Release, names, patterns []string) ([]service return selected, nil } -// downloadArchive downloads the source code of the tag as a zip or tar.gz file. func (a *application) downloadArchive(cmd *cobra.Command, service services.Releases, flags downloadFlags, tag, format string) error { _, repo, _ := strings.Cut(service.Repository, "/") name := repo + "-" + strings.ReplaceAll(tag, "/", "-") + "." + format @@ -311,7 +308,6 @@ func (a *application) downloadArchive(cmd *cobra.Command, service services.Relea return nil } -// countingReader counts the bytes read through it. type countingReader struct { reader io.Reader n int64 diff --git a/internal/app/releases.go b/internal/app/releases.go index 210a9477..b9dc4094 100644 --- a/internal/app/releases.go +++ b/internal/app/releases.go @@ -139,7 +139,6 @@ $ tea release view v1.0.0 --web`, return command } -// releaseView shows a release with its notes as the body. func (a *application) releaseView(item services.Release, latest int64) terminal.View { view := terminal.View{Title: cmp.Or(item.Title, item.Tag), Body: item.Body, Kind: "release", URL: item.URL} if item.Title != "" && item.Title != item.Tag { @@ -187,7 +186,6 @@ func (f *releaseFields) register(a *application, cmd *cobra.Command) { _ = cmd.RegisterFlagCompletionFunc("target", a.completeBranches()) } -// options returns the release fields whose flags were passed. func (f *releaseFields) options(a *application, cmd *cobra.Command) (services.ReleaseOptions, error) { opts := services.ReleaseOptions{} changed := cmd.Flags().Changed diff --git a/internal/app/repo_mutations.go b/internal/app/repo_mutations.go index fb0cc438..1de1b5fa 100644 --- a/internal/app/repo_mutations.go +++ b/internal/app/repo_mutations.go @@ -166,14 +166,12 @@ $ tea repo edit --default-merge-style squash --add-topic cli --add-topic go`, return services.Classify(err, "branch", strconv.Quote(defaultBranch), t.Repo) } } - if options.Archived != nil && *options.Archived { - if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Archive " + t.Repo + "? It becomes read-only."}); err != nil { - return err - } - } // Gitea refuses topic changes on an archived repository, so archiving comes last. repo, archive := t.Repo, options.Archived != nil && *options.Archived if archive { + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Archive " + t.Repo + "? It becomes read-only."}); err != nil { + return err + } options.Archived = nil } if options != (services.RepositoryEdit{}) { @@ -241,12 +239,9 @@ func (a *application) repoCloneCommand() *cobra.Command { command := &cobra.Command{ Use: "clone [] [-- ...]", Short: "Clone a repository locally", - Long: `Clone a repository with the configured Git protocol (git_protocol: https or ssh) and tea's -credentials. Git flags can follow --. - -A fork's parent is added as the "upstream" remote, which its default branch pulls from while pushes -stay on the fork; tea commands in the clone then target the parent. A pull mirror's source is added -as the "source" remote.`, + Long: `Clone a repository with the configured Git protocol and tea's credentials. Git flags can +follow --. A fork's parent is added as the "upstream" remote, and tea commands in the clone then +target the parent.`, Example: `# Clone a repository into ./project $ tea repo clone alice/project @@ -304,12 +299,11 @@ func (a *application) repoForkCommand() *cobra.Command { command := &cobra.Command{ Use: "fork [] [-- ...]", Short: "Fork a repository", - Long: `Fork a repository for your account, or into an organization with --org. When the account or -organization already has a fork, tea reports it instead of failing. + Long: `Fork a repository for your account, or into an organization with --org. An existing fork is +reported instead of failing. -With --clone, the fork is cloned, and its parent is added as the "upstream" remote; Git flags can -follow --. Inside a clone of the repository, --remote adds the fork as the "origin" remote (or ---remote-name), renaming the current "origin" to "upstream".`, +--clone clones the fork (Git flags can follow --). Inside a clone of the repository, --remote adds +the fork as "origin" and renames the current "origin" to "upstream".`, Example: `# Fork the repository of the current clone and add the fork as origin $ tea repo fork --remote @@ -502,18 +496,13 @@ func (a *application) inferredRepository(t target, verb string, refuseYes bool) if t.RepoSource == "--repo" { return "", nil } - source := describeRepoSource(t.RepoSource) + source := strings.Replace(t.RepoSource, "remote ", "git remote ", 1) 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{ @@ -581,7 +570,7 @@ $ tea repo archive alice/old-project --yes`, } func (a *application) repoRenameCommand() *cobra.Command { - command := &cobra.Command{ + return &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.", @@ -625,16 +614,13 @@ $ tea repo rename new-name -R alice/old-name`, return a.jsonResult(cmd, item) }, } - return command } 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.`, + Long: "Delete a repository with its issues, pull requests, releases and wiki. This cannot be undone.", Example: `# Delete a repository after typing its name $ tea repo delete alice/old-project diff --git a/internal/app/repos.go b/internal/app/repos.go index a0154493..fb7005f9 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -61,10 +61,8 @@ func (a *application) repoSetDefaultCommand() *cobra.Command { command := &cobra.Command{ Use: "set-default []", Short: "Set the default repository for this clone", - Long: `Set the repository that tea commands use in the current Git clone, instead of the one found through -its Git remotes. It is saved as tea.repository in the clone's Git configuration. - -Without an argument, the repository that tea selects now is saved.`, + Long: `Set the repository that tea commands use in the current Git clone instead of the one of its Git +remotes (saved as tea.repository in the clone's Git configuration).`, Example: `# Make tea commands in this clone use the fork's parent $ tea repo set-default alice/project @@ -280,7 +278,6 @@ $ tea repo view alice/project --web`, return view } -// repositoryView shows a repository with its README as the body. func (a *application) repositoryView(item services.Repository, readme string) terminal.View { badges := []terminal.Cell{{Text: "public", Color: "green"}} if item.Private { diff --git a/internal/app/repository_create.go b/internal/app/repository_create.go index b7fe1ff2..0a0263f1 100644 --- a/internal/app/repository_create.go +++ b/internal/app/repository_create.go @@ -36,11 +36,9 @@ func (a *application) repoCreateCommand() *cobra.Command { Short: "Create a repository", Long: `Create a repository for your account, or for an organization as ORG/NAME or with --org. -A new repository is empty unless --auto-init adds a first commit with a README and, optionally, a -.gitignore and a license from the server's templates. --template copies another repository instead. - ---source publishes an existing local Git repository: it adds the new repository as a remote of -it (origin unless --remote names another), and --push pushes the current branch there.`, +--auto-init adds a first commit with a README, .gitignore and license; --template copies another +repository. --source adds the new repository as a remote of a local Git repository, and --push +pushes its current branch there.`, Example: `# Create a private repository with a README and an MIT license $ tea repo create project --private --auto-init --license MIT @@ -83,7 +81,7 @@ $ tea repo create project --source . --push`, return err } } - prompted, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", func(name string) error { return validateRepositoryName(name) }) + prompted, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", validateRepositoryName) if err != nil { return err } @@ -130,11 +128,8 @@ $ tea repo create project --source . --push`, } var item services.Repository if template != "" { - templateOpts.Name = opts.Name - templateOpts.Owner = org - templateOpts.Description = opts.Description - templateOpts.Private = opts.Private - templateOpts.DefaultBranch = opts.DefaultBranch + templateOpts.Name, templateOpts.Owner, templateOpts.Description = opts.Name, org, opts.Description + templateOpts.Private, templateOpts.DefaultBranch = opts.Private, opts.DefaultBranch item, err = service.Generate(cmd.Context(), target.Repo, templateOpts) } else { item, err = service.Create(cmd.Context(), org, opts) diff --git a/internal/app/repository_migrate.go b/internal/app/repository_migrate.go index 0a3081ff..2de8d51a 100644 --- a/internal/app/repository_migrate.go +++ b/internal/app/repository_migrate.go @@ -44,12 +44,9 @@ func (a *application) repoMigrateCommand() *cobra.Command { command := &cobra.Command{ Use: "migrate []", Short: "Import a repository from another server", - Long: `Import a repository into Gitea from a Git URL, or from another forge with --service, which also -copies its issues, labels, milestones, pull requests, releases or wiki on request. --mirror creates -a pull mirror that Gitea updates from the source instead. - -The repository is named after the last segment of the URL unless --name is given. Imports from a -forge with --service need an access token for it.`, + Long: `Import a repository from a Git URL, or from another forge with --service (which needs an access +token and can also copy issues, pull requests, releases and more). --mirror creates a pull mirror +instead. The name defaults to the last segment of the URL.`, Example: `# Import a public Git repository $ tea repo migrate https://github.com/go-gitea/tea.git @@ -81,7 +78,7 @@ $ tea repo migrate https://github.com/acme/tool --service github --issues --pull if err := validateMigrationFlags(cmd, opts, passwordFile, tokenFile); err != nil { return err } - validURL := func(raw string) error { return validateMigrationURL(raw) } + validURL := validateMigrationURL if cmd.Flags().Changed("server-path") { validURL = nil } @@ -95,7 +92,7 @@ $ tea repo migrate https://github.com/acme/tool --service github --issues --pull } } opts.Name = cmp.Or(opts.Name, migrationName(opts.CloneURL)) - named, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", func(name string) error { return validateRepositoryName(name) }) + named, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", validateRepositoryName) if err != nil { return err } diff --git a/internal/app/repository_sync.go b/internal/app/repository_sync.go index 0a7e52b5..4aa68f2a 100644 --- a/internal/app/repository_sync.go +++ b/internal/app/repository_sync.go @@ -21,14 +21,11 @@ func (a *application) repositorySyncCommand() *cobra.Command { command := &cobra.Command{ Use: "sync []", Short: "Sync a branch with its remote or its fork's parent", - Long: `Fast-forward a local branch to the branch of the same name on the repository's Git remote. + Long: `Fast-forward a local branch (default: the repository's default branch) to the same branch on +the repository's Git remote, without switching branches. Diverged branches are left unchanged. -The branch defaults to the repository's default branch. It is created when it is missing, and it is -never checked out: the branch you are on stays checked out. When the branch is checked out, the -working tree must be clean. Branches that have diverged from the remote are left unchanged. - -Given a repository that has no remote in the current directory, sync updates it on the server -instead: a fork's branch is fast-forwarded to its parent's, and a pull mirror fetches its source.`, +Given a repository without a Git remote here, sync updates it on the server instead: a fork +fast-forwards to its parent, and a pull mirror fetches its source.`, Example: `# Fast-forward the default branch from the repository's remote $ tea repo sync diff --git a/internal/app/teams.go b/internal/app/teams.go index c8295e61..adc412f9 100644 --- a/internal/app/teams.go +++ b/internal/app/teams.go @@ -40,7 +40,6 @@ func parseTeam(raw string) (teamRef, error) { return teamRef{}, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid team %q: use ORG/TEAM, such as acme/developers, or a team ID", raw)) } -// find looks the team up on the instance. func (r teamRef) find(ctx context.Context, service services.Teams) (services.Team, error) { if r.org != "" { return service.Find(ctx, r.org, r.name) @@ -63,7 +62,6 @@ func (a *application) team(cmd *cobra.Command, raw string) (target, services.Tea return t, service, team, err } -// teamURL is the page of a team on its instance. func teamURL(t target, team services.Team) string { return webURL(t, "org", team.Organization, "teams", strings.ToLower(team.Name)) } @@ -284,7 +282,6 @@ func (f *teamFlags) unitPolicy(cmd *cobra.Command, create bool) (map[string]stri return policy, nil } -// options returns the supplied team fields. func (f *teamFlags) options(cmd *cobra.Command) services.TeamOptions { opts := services.TeamOptions{} for _, field := range []struct { @@ -436,10 +433,7 @@ func (a *application) teamDeleteCommand() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "delete ", Short: "Delete a team", Args: argsExact(1), ValidArgsFunction: a.completeTeams(), - Long: `Delete a team. Its members lose the access the team gave them. - -Deleting an organization's owner team takes away owner access, so it needs --force and, on a -terminal, typing ORG/TEAM.`, + Long: "Delete a team. Deleting an organization's owner team needs --force.", Example: `$ tea team delete acme/reviewers $ tea team delete 7 --yes`, Annotations: map[string]string{"tea.text": "team deletion does not return a resource", "tea.confirm": "true", "tea.arguments": teamArguments}, @@ -610,7 +604,6 @@ its organization. Repositories are named alone, such as service, or as ORG/NAME. return group } -// teamRepoAccessCommand grants or revokes team access to repositories. func (a *application) teamRepoAccessCommand(grant bool) *cobra.Command { verb, short, example := "remove", "Revoke team access to repositories", "$ tea team repo remove acme/developers legacy" annotations := map[string]string{"tea.text": "team access changes do not return a resource", "tea.arguments": teamArguments, "tea.confirm": "true"}