fix(config): print only the value of config get and add display preferences

tea config get printed "key<TAB>value", so $(tea config get editor) was
not usable; it now prints the value alone, and --show-source appends the
source (D18). config get reads the instance's user, and instance settings
come from the selected instance (--host, TEA_HOST, default_host or the
sole instance). Unknown keys list the valid ones, and keys that need
--host or --user say so. config path no longer loads the settings, so it
works when hosts.yml is broken. config list is a table on a terminal.
config set confirms the change on a terminal and warns when default_host
has no account.

config.yml stores the hyperlinks (auto, always, never) and markdown
(auto, raw) preferences that tables and views now read, instead of the
TEA_HYPERLINKS and TEA_MARKDOWN variables. The environment table moves
out of tea config --help into tea help environment.

alias set refuses to replace an alias without --clobber, drops a leading
"tea", and explains how to quote an expansion with spaces or flags.
alias delete takes several names or --all, alias list is a table, and
an empty list says so on a terminal.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:49:52 +02:00
1 parent 0dfe7def9b
commit 5e8aa1011f
9 files changed
+687 -271

No files matched your search

+1 -7
View File
@@ -279,7 +279,7 @@ func (a *application) checkOutputFlags(cmd *cobra.Command) error {
// offline reports commands that must work without valid settings.
func offline(cmd *cobra.Command) bool {
if cmd.Annotations["tea.group"] == "true" {
if cmd.Annotations["tea.group"] == "true" || cmd.Annotations["tea.offline"] == "true" {
return true
}
switch cmd.Name() {
@@ -492,12 +492,6 @@ func commandIndex(root *cobra.Command, args []string) int {
return len(args)
}
// configKeys are the preferences, instance settings and account settings that config get reads.
var configKeys = []string{
"browser", "color", "default_host", "editor", "git_protocol", "insecure", "oauth_client_id", "pager", "prompt",
"remote", "ssh_agent", "ssh_certificate_principal", "ssh_host", "ssh_key", "ssh_key_fingerprint", "version",
}
func commandOutputFields(cmd *cobra.Command) []string {
if cmd.Name() == "status" && cmd.Parent() != nil {
if cmd.Parent().Name() == "pr" {
-10
View File
@@ -415,16 +415,11 @@ func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) {
// have yet to complete; see TestHelpTextsAreComplete.
const (
shortsToFix = `
config get
config list
notification mark-read
notification mark-unread
pr review cancel-request
`
examplesToWrite = `
alias delete
alias list
alias set
api
auth login
auth logout
@@ -439,10 +434,6 @@ const (
branch rename
branch unprotect
branch view
config get
config list
config path
config set
doctor
issue attachment delete
issue attachment download
@@ -557,7 +548,6 @@ const (
wiki view
`
longsToWrite = `
alias
auth
branch
issue
-9
View File
@@ -255,15 +255,6 @@ func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete s
return matching(describe(slices.Sorted(maps.Keys(aliases)), func(name string) (string, string) { return name, aliases[name] }), toComplete), cobra.ShellCompDirectiveNoFileComp
}
// configValues lists the values of the settings that accept a fixed set.
var configValues = map[string][]string{
"git_protocol": {"https", "ssh"},
"prompt": {"enabled", "disabled"},
"color": {"auto", "always", "never"},
"insecure": {"true", "false"},
"ssh_agent": {"true", "false"},
}
// completeConfig completes a setting name, then its value: the fixed values, the configured instances
// for default_host, or the Git remotes for remote.
func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
+476 -234
View File
@@ -6,8 +6,10 @@ package app
import (
"errors"
"fmt"
"maps"
"path/filepath"
"sort"
"slices"
"strconv"
"strings"
"gitea.dev/tea/internal/aliases"
@@ -17,6 +19,26 @@ import (
"github.com/spf13/cobra"
)
// Settings that tea config reads and writes, by where they are stored.
var (
preferenceKeys = []string{"browser", "color", "default_host", "editor", "git_protocol", "hyperlinks", "markdown", "pager", "prompt", "remote"}
instanceKeys = []string{"git_protocol", "insecure", "oauth_client_id", "ssh_host", "user"}
accountKeys = []string{"ssh_agent", "ssh_certificate_principal", "ssh_key", "ssh_key_fingerprint"}
// configKeys are every setting that config get reads, in alphabetical order.
configKeys = slices.Compact(slices.Sorted(slices.Values(slices.Concat(preferenceKeys, instanceKeys, accountKeys, []string{"version"}))))
)
// configValues lists the values of the settings that accept a fixed set.
var configValues = map[string][]string{
"git_protocol": {"https", "ssh"},
"prompt": {"enabled", "disabled"},
"color": {"auto", "always", "never"},
"hyperlinks": {"auto", "always", "never"},
"markdown": {"auto", "raw"},
"insecure": {"true", "false"},
"ssh_agent": {"true", "false"},
}
func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) {
preferences := snapshot.Preferences
sources := map[string]string{}
@@ -42,7 +64,7 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
}
}
if host != "" {
normalized, err := settings.NormalizeHost(host)
normalized, err := settings.ResolveHost(host, snapshot.Hosts.Hosts)
if err != nil {
return preferences, nil, clierr.Wrap(clierr.Usage, err)
}
@@ -85,269 +107,489 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
return preferences, sources, nil
}
// configSetting is one effective setting and where its value comes from.
type configSetting struct {
Key string
Value any
Source string
}
// settingValues returns the effective preferences, the settings of the selected instance when it is
// configured, and with --user the settings of that account, each with its source.
func (a *application) settingValues(snapshot settings.Snapshot) (map[string]any, map[string]string, error) {
if a.options.user != "" && a.options.host == "" {
return nil, nil, clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
preferences, sources, err := a.effectivePreferences(snapshot)
if err != nil {
return nil, nil, err
}
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"hyperlinks": preferences.Hyperlinks, "markdown": preferences.Markdown,
"remote": preferences.Remote,
}
host, configured := snapshot.Hosts.Hosts[preferences.DefaultHost]
if !configured {
if a.options.user != "" {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", preferences.DefaultHost, preferences.DefaultHost))
}
return values, sources, nil
}
for key, value := range map[string]any{"user": host.User, "ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, preferences.DefaultHost, accountNames(host.Users)))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
return values, sources, nil
}
// unknownSetting explains why key has no value: it names an account or instance setting that needs
// --host or --user, or no setting at all.
func unknownSetting(key string) error {
switch {
case slices.Contains(accountKeys, key):
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an account setting; select the account with --host URL --user NAME", key))
case slices.Contains(instanceKeys, key):
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an instance setting; select a configured instance with --host URL", key))
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; settings: %s", key, strings.Join(configKeys, ", ")))
}
func (a *application) configCommand() *cobra.Command {
group := &cobra.Command{Use: "config", Short: "Manage preferences", Long: `Manage preferences.
group := &cobra.Command{Use: "config", Short: "Manage settings", Long: `Manage tea's settings.
Settings live in config.yml and hosts.yml in the configuration directory
($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both.
Preferences live in config.yml and instance settings in hosts.yml, in the configuration
directory ($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints
both files. Environment variables that override settings are listed in tea help environment.
config.yml preferences (tea config set <key> <value>):
default_host instance URL used when no --host, environment or repository selects one
git_protocol https or ssh for clone and checkout remotes
editor command used to edit bodies
Preferences (tea config set <key> <value>):
default_host instance used when no --host, environment variable or repository selects one
git_protocol https or ssh, for clone and checkout remotes
editor command that edits bodies
prompt enabled or disabled
pager command used to page human output
browser command used to open web pages; never disables opening
pager command that pages long output
browser command that opens web pages; never disables opening
color auto, always or never
remote Git remote used for repository discovery
aliases command aliases; manage them with tea alias
hyperlinks auto, always or never: link the first column of lists to its web page
markdown auto or raw: render markdown bodies on a terminal, or print them as written
remote Git remote that selects the repository
aliases command shortcuts; manage them with tea alias
hosts.yml per-instance settings (tea config set --host <url> <key> <value>):
user active account, changed only by tea auth switch
Instance settings (tea config set --host <url> <key> <value>):
user active account; change it with tea auth switch
ssh_host SSH host[:port] when it differs from the web host
insecure true skips TLS certificate verification
git_protocol https or ssh, overriding config.yml for this instance
oauth_client_id OAuth application used by tea auth login
git_protocol https or ssh, overriding the preference for this instance
oauth_client_id OAuth application used by tea auth login --oauth
Per-account SSH settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint
Environment overrides:
TEA_CONFIG_DIR configuration directory
TEA_HOST, GITEA_INSTANCE_URL instance, overriding default_host
GITEA_TOKEN access token for that instance instead of stored credentials
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes
TEA_USER account on the selected instance
TEA_REPO repository as owner/name or URL
TEA_EDITOR editor, overriding config.yml
TEA_PAGER pager, overriding config.yml
TEA_COLOR color preference; NO_COLOR disables automatic color
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none`}
group.AddCommand(&cobra.Command{Use: "path", Short: "Show configuration file paths", Annotations: textOutput("config path prints text"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml"))
return err
}})
for _, name := range []string{"get", "list"} {
var source bool
command := &cobra.Command{Use: name, Short: "Show effective preferences"}
command.Flags().BoolVar(&source, "show-source", false, "Show the source of each effective value")
if name == "get" {
command.Use = "get <key>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.RunE = func(cmd *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
Account settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint`}
group.AddCommand(&cobra.Command{
Use: "path", Short: "Print the paths of the settings files", Args: argsExact(0),
Annotations: map[string]string{"tea.text": "config path prints text", "tea.offline": "true"},
Example: "$ tea config path\n# Settings of another configuration directory\n$ TEA_CONFIG_DIR=~/work/tea tea config path",
RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml"))
return err
},
})
var getSource bool
get := &cobra.Command{
Use: "get <key>", Short: "Print the value of a setting", Args: argsExact(1), ValidArgsFunction: a.completeConfig,
Example: `$ tea config get editor
# Show where the value comes from
$ tea config get default_host --show-source
# Read an instance or account setting
$ tea config get ssh_host --host https://gitea.com
$ tea config get ssh_key --host https://gitea.com --user alice`,
RunE: func(cmd *cobra.Command, args []string) error {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
preferences, sources, err := a.effectivePreferences(snapshot)
values, sources, err := a.settingValues(snapshot)
if err != nil {
return err
}
if _, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.user != "" && !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"remote": preferences.Remote,
}
if host, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.host != "" && exists {
for key, value := range map[string]any{"ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
}
if len(args) > 0 {
value, ok := values[args[0]]
if !ok {
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown preference %q", args[0]))
}
values = map[string]any{args[0]: value}
value, ok := values[args[0]]
if !ok {
return unknownSetting(args[0])
}
if a.options.json != "" {
if source {
results := map[string]any{}
for key, value := range values {
origin := sources[key]
if origin == "" {
origin = "default"
}
results[key] = struct {
Value any `json:"value"`
Source string `json:"source"`
}{value, origin}
}
return a.writeJSON(cmd, results)
}
return a.writeJSON(cmd, values)
return a.writeSettingsJSON(cmd, map[string]any{args[0]: value}, sources, getSource)
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
line := fmt.Sprint(value)
if getSource {
line += "\t" + settingSource(sources, args[0])
}
sort.Strings(keys)
for _, key := range keys {
suffix := ""
if source {
origin := "default"
if sources[key] != "" {
origin = sources[key]
_, err = fmt.Fprintln(cmd.OutOrStdout(), line)
return err
},
}
get.Flags().BoolVar(&getSource, "show-source", false, "Also print where the value comes from")
group.AddCommand(get)
var listSource bool
list := &cobra.Command{
Use: "list", Short: "List settings and their values", Args: argsExact(0),
Example: `$ tea config list
# Include the settings of an instance and where each value comes from
$ tea config list --host https://gitea.com --show-source`,
RunE: func(cmd *cobra.Command, _ []string) error {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
values, sources, err := a.settingValues(snapshot)
if err != nil {
return err
}
if a.options.json != "" {
return a.writeSettingsJSON(cmd, values, sources, listSource)
}
items := make([]configSetting, 0, len(values))
for _, key := range slices.Sorted(maps.Keys(values)) {
items = append(items, configSetting{key, values[key], settingSource(sources, key)})
}
columns := []terminal.Column{{Header: "Key"}, {Header: "Value", Flex: true}}
if listSource {
columns = append(columns, terminal.Column{Header: "Source"})
}
return writeTable(a, cmd, items, listSpec[configSetting]{
Columns: columns,
Row: func(item configSetting) []terminal.Cell {
row := []terminal.Cell{{Text: item.Key}, {Text: fmt.Sprint(item.Value)}}
if listSource {
row = append(row, terminal.Cell{Text: item.Source, Muted: true})
}
suffix = "\t" + origin
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil {
return row
},
}, listPage{Number: 1})
},
}
list.Flags().BoolVar(&listSource, "show-source", false, "Show where each value comes from")
group.AddCommand(list)
group.AddCommand(&cobra.Command{
Use: "set <key> <value>", Short: "Change a setting", Args: argsExact(2), ValidArgsFunction: a.completeConfig,
Annotations: textOutput("config set does not return a resource"),
Example: `$ tea config set editor "code --wait"
$ tea config set git_protocol ssh
# Keep raw markdown and plain IDs on a terminal
$ tea config set markdown raw
$ tea config set hyperlinks never
# Change an instance or account setting
$ tea config set ssh_host git.example.com:2222 --host https://example.com
$ tea config set ssh_key ~/.ssh/id_ed25519 --host https://example.com --user alice`,
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host == "" {
value, err := a.setPreference(key, value)
if err != nil {
return err
}
a.reportSet(cmd, key, value, "")
if key == "default_host" && value != "" {
a.warnWithoutAccount(cmd)
}
return nil
}
return nil
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "set <key> <value>", Short: "Set a preference, or an instance setting with --host", Annotations: textOutput("config set does not return a resource"), Args: argsExact(2), RunE: func(_ *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host != "" {
instance, err := settings.NormalizeHost(a.options.host)
instance, err := a.setInstanceSetting(key, value)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
return err
}
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
switch args[0] {
case "ssh_key":
account.SSHKey = args[1]
case "ssh_agent":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = args[1] == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = args[1]
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = args[1]
default:
return clierr.Wrap(clierr.Usage, errors.New("supported account settings: ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint"))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch args[0] {
case "git_protocol":
host.GitProtocol = args[1]
case "ssh_host":
host.SSHHost = args[1]
case "insecure":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = args[1] == "true"
case "oauth_client_id":
host.OAuthClientID = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("preference %q cannot be set per instance; supported keys: git_protocol, ssh_host, insecure, oauth_client_id", args[0]))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch args[0] {
case "default_host":
if args[1] == "" {
p.DefaultHost = ""
break
}
normalized, err := settings.NormalizeHost(args[1])
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
p.DefaultHost = normalized
case "git_protocol":
p.GitProtocol = args[1]
case "editor":
p.Editor = args[1]
case "prompt":
p.Prompt = args[1]
case "pager":
p.Pager = args[1]
case "browser":
p.Browser = args[1]
case "color":
p.Color = args[1]
case "remote":
p.Remote = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown or immutable preference %q", args[0]))
scope := " for " + instance
if a.options.user != "" {
scope = " for " + a.options.user + " on " + instance
}
return clierr.Wrap(clierr.Usage, s.Validate())
})
}})
a.reportSet(cmd, key, value, scope)
return nil
},
})
return group
}
func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"}
group.AddCommand(&cobra.Command{Use: "set <name> <expansion>", Short: "Define a command alias", Annotations: textOutput("alias set does not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
s.Preferences.Aliases[args[0]] = args[1]
return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root())))
})
}})
group.AddCommand(&cobra.Command{Use: "delete <name>", Short: "Remove a command alias", Annotations: textOutput("alias deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
if _, ok := s.Preferences.Aliases[args[0]]; !ok {
return clierr.Wrap(clierr.Usage, errors.New("alias does not exist"))
// settingSource names where the value of key comes from.
func settingSource(sources map[string]string, key string) string {
if source := sources[key]; source != "" {
return source
}
return "default"
}
// writeSettingsJSON writes settings as a key-value object, or with source as objects of value and source.
func (a *application) writeSettingsJSON(cmd *cobra.Command, values map[string]any, sources map[string]string, source bool) error {
if !source {
return a.writeJSON(cmd, values)
}
type sourced struct {
Value any `json:"value"`
Source string `json:"source"`
}
results := map[string]sourced{}
for key, value := range values {
results[key] = sourced{value, settingSource(sources, key)}
}
return a.writeJSON(cmd, results)
}
// reportSet confirms a changed setting; scope names the instance or account it belongs to.
func (a *application) reportSet(cmd *cobra.Command, key, value, scope string) {
if value == "" {
a.done(cmd, "Cleared %s%s", key, scope)
return
}
a.done(cmd, "Set %s%s to %s", key, scope, value)
}
// warnWithoutAccount points out a default_host that has no account to act as yet.
func (a *application) warnWithoutAccount(cmd *cobra.Command) {
snapshot, err := a.deps.Settings.Load()
if host := snapshot.Preferences.DefaultHost; err == nil && len(snapshot.Hosts.Hosts[host].Users) == 0 {
a.warn(cmd, "No account on %s yet; log in with: tea auth login --host %s", host, host)
}
}
// setPreference changes a preference in config.yml and returns the stored value, such as a
// normalized default_host.
func (a *application) setPreference(key, value string) (string, error) {
return value, a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch key {
case "default_host":
if value != "" {
normalized, err := settings.ResolveHost(value, s.Hosts.Hosts)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
value = normalized
}
p.DefaultHost = value
case "git_protocol":
p.GitProtocol = value
case "editor":
p.Editor = value
case "prompt":
p.Prompt = value
case "pager":
p.Pager = value
case "browser":
p.Browser = value
case "color":
p.Color = value
case "hyperlinks":
p.Hyperlinks = value
case "markdown":
p.Markdown = value
case "remote":
p.Remote = value
case "user":
return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch"))
default:
if slices.Contains(instanceKeys, key) || slices.Contains(accountKeys, key) {
return unknownSetting(key)
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; preferences: %s", key, strings.Join(preferenceKeys, ", ")))
}
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
// setInstanceSetting changes a setting of the --host instance in hosts.yml, or with --user one of its
// account's settings, and returns the instance.
func (a *application) setInstanceSetting(key, value string) (string, error) {
var instance string
return instance, a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
var err error
if instance, err = settings.ResolveHost(a.options.host, s.Hosts.Hosts); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", instance, instance))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, instance, accountNames(host.Users)))
}
account := host.Users[user]
switch key {
case "ssh_key":
account.SSHKey = value
case "ssh_agent":
if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = value == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = value
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = value
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an account setting; account settings: %s", key, strings.Join(accountKeys, ", ")))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch key {
case "git_protocol":
host.GitProtocol = value
case "ssh_host":
host.SSHHost = value
case "insecure":
if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = value == "true"
case "oauth_client_id":
host.OAuthClientID = value
case "user":
return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch"))
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an instance setting; instance settings: git_protocol, insecure, oauth_client_id, ssh_host", key))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Create shortcuts for tea commands", Long: `Create shortcuts for tea commands.
An alias expands to a tea command with its arguments before tea runs it: after
tea alias set il "issue list --label bug", tea il --state all runs
tea issue list --label bug --state all. $1, $2 and so on in the expansion take the
arguments in that position; other arguments follow the expansion. Aliases never run a
shell, and built-in commands always win over an alias of the same name.`}
var clobber bool
set := &cobra.Command{
Use: "set <name> <expansion>", Short: "Create a shortcut for a tea command",
Annotations: textOutput("alias set does not return a resource"),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 2 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the expansion must be one argument; quote it: tea alias set %s %q", args[0], strings.Join(args[1:], " ")))
}
return argsExact(2)(cmd, args)
},
Example: `# tea il runs tea issue list --label bug
$ tea alias set il "issue list --label bug"
# $1 takes the first argument: tea review 13 runs tea pr review 13 --approve
$ tea alias set review 'pr review $1 --approve'
# Replace an existing alias
$ tea alias set il "issue list --assignee @me" --clobber`,
RunE: func(cmd *cobra.Command, args []string) error {
name, expansion := args[0], strings.TrimPrefix(strings.TrimSpace(args[1]), "tea ")
var previous string
err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
previous = s.Preferences.Aliases[name]
if previous != "" && !clobber {
return fmt.Errorf("alias %s already expands to %q; replace it with --clobber", name, previous)
}
s.Preferences.Aliases[name] = expansion
return clierr.Wrap(clierr.Usage, aliases.Check(name, s.Preferences.Aliases, builtins(cmd.Root())))
})
if err != nil {
return err
}
if previous != "" {
a.done(cmd, "Changed alias %s from %q to %q", name, previous, expansion)
} else {
a.done(cmd, "Added alias %s for %q", name, expansion)
}
delete(s.Preferences.Aliases, args[0])
return nil
})
}})
group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases)
}
names := make([]string, 0, len(s.Preferences.Aliases))
for name := range s.Preferences.Aliases {
names = append(names, name)
}
sort.Strings(names)
return a.writeHuman(cmd, table(names, func(name string) []terminal.Cell { return terminal.Row(name, s.Preferences.Aliases[name]) }, a.colorEnabled()))
}})
},
}
set.Flags().BoolVar(&clobber, "clobber", false, "Replace an existing alias of the same name")
set.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%w; quote an expansion with flags: tea alias set il \"issue list --state all\"", err))
})
group.AddCommand(set)
var all bool
remove := &cobra.Command{
Use: "delete [<name>...]", Short: "Delete shortcuts", ValidArgsFunction: a.completeAliases,
Annotations: textOutput("alias deletion does not return a resource"),
Args: func(_ *cobra.Command, args []string) error {
if len(args) == 0 && !all {
return clierr.Wrap(clierr.Usage, errors.New("name the aliases to delete, or pass --all"))
}
if len(args) > 0 && all {
return clierr.Wrap(clierr.Usage, errors.New("alias names and --all are mutually exclusive"))
}
return nil
},
Example: "$ tea alias delete il\n$ tea alias delete il review\n$ tea alias delete --all",
RunE: func(cmd *cobra.Command, args []string) error {
deleted := map[string]string{}
err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
names := args
if all {
names = slices.Collect(maps.Keys(s.Preferences.Aliases))
}
var missing []string
for _, name := range names {
expansion, ok := s.Preferences.Aliases[name]
if !ok {
missing = append(missing, strconv.Quote(name))
continue
}
deleted[name] = expansion
delete(s.Preferences.Aliases, name)
}
if len(missing) > 0 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("no alias named %s; tea alias list shows them", strings.Join(missing, ", ")))
}
return nil
})
if err != nil {
return err
}
if len(deleted) == 0 {
a.warn(cmd, "No aliases to delete")
}
for _, name := range slices.Sorted(maps.Keys(deleted)) {
a.done(cmd, "Deleted alias %s (%s)", name, deleted[name])
}
return nil
},
}
remove.Flags().BoolVar(&all, "all", false, "Delete every alias")
group.AddCommand(remove)
group.AddCommand(&cobra.Command{
Use: "list", Short: "List shortcuts", Args: argsExact(0),
Example: "$ tea alias list\n# Print the expansion of one alias\n$ tea alias list --jq .il",
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases)
}
return writeTable(a, cmd, slices.Sorted(maps.Keys(s.Preferences.Aliases)), listSpec[string]{
Columns: []terminal.Column{{Header: "Name"}, {Header: "Expansion", Flex: true}},
Row: func(name string) []terminal.Cell {
return []terminal.Cell{{Text: name, Bold: true}, {Text: s.Preferences.Aliases[name]}}
},
Empty: "no aliases configured; create one with: tea alias set <name> <expansion>",
}, listPage{Number: 1})
},
})
return group
}
+164 -3
View File
@@ -7,7 +7,9 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
@@ -94,11 +96,170 @@ func TestConfigHelpDocumentsSettingsAndEnvironment(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "--help"}))
for _, name := range []string{
"default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "remote", "aliases",
"default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "hyperlinks", "markdown", "remote", "aliases",
"user", "ssh_host", "insecure", "oauth_client_id", "ssh_key", "ssh_agent", "ssh_certificate_principal", "ssh_key_fingerprint",
"TEA_CONFIG_DIR", "TEA_HOST", "GITEA_INSTANCE_URL", "GITEA_TOKEN", "TEA_USER", "TEA_REPO", "TEA_EDITOR", "VISUAL", "EDITOR",
"TEA_PAGER", "PAGER", "TEA_COLOR", "NO_COLOR", "BROWSER",
"tea help environment",
} {
require.Contains(t, out.String(), name)
}
require.NotContains(t, out.String(), "GITEA_TOKEN", "the environment table lives in tea help environment")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"help", "environment"}))
for _, name := range []string{"TEA_CONFIG_DIR", "TEA_HOST", "GITEA_INSTANCE_URL", "GITEA_TOKEN", "TEA_USER", "TEA_REPO", "TEA_EDITOR", "VISUAL", "EDITOR", "TEA_PAGER", "PAGER", "TEA_COLOR", "NO_COLOR", "BROWSER"} {
require.Contains(t, out.String(), name)
}
}
func TestConfigGetPrintsOnlyTheValue(t *testing.T) {
deps, out, _ := testDeps(t)
configureHost(t, deps, "http://localhost:3001", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice", "bob")})
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "editor", "code --wait"}))
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "get", "editor"}, "code --wait\n"},
{[]string{"config", "get", "editor", "--show-source"}, "code --wait\tconfig.yml\n"},
{[]string{"config", "get", "pager"}, "\n"},
{[]string{"config", "get", "hyperlinks", "--show-source"}, "auto\tdefault\n"},
{[]string{"config", "get", "user", "--host", "http://localhost:3001"}, "alice\n"},
{[]string{"config", "get", "user", "--host", "localhost:3001", "--show-source"}, "alice\thosts.yml http://localhost:3001\n"},
{[]string{"config", "get", "user"}, "alice\n"},
{[]string{"config", "get", "insecure"}, "false\n"},
} {
t.Run(strings.Join(tc.args[2:], " "), func(t *testing.T) {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Equal(t, tc.want, out.String())
})
}
}
func TestConfigNamesValidSettings(t *testing.T) {
deps, _, _ := testDeps(t)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "get", "nope"}, `unknown setting "nope"; settings: browser, color, default_host, editor, git_protocol, hyperlinks, insecure, markdown,`},
{[]string{"config", "get", "user"}, "user is an instance setting; select a configured instance with --host URL"},
{[]string{"config", "get", "ssh_key"}, "ssh_key is an account setting; select the account with --host URL --user NAME"},
{[]string{"config", "set", "nope", "1"}, `unknown setting "nope"; preferences: browser, color, default_host,`},
{[]string{"config", "set", "user", "bob"}, "the active account changes with tea auth switch"},
{[]string{"config", "set", "hyperlinks", "off"}, "hyperlinks must be auto, always, or never"},
{[]string{"config", "set", "markdown", "never"}, "markdown must be auto or raw"},
} {
t.Run(strings.Join(tc.args[1:], " "), func(t *testing.T) {
err := Execute(context.Background(), deps, tc.args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, tc.want)
})
}
}
func TestConfigPathWorksWithBrokenSettings(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, os.WriteFile(filepath.Join(deps.Settings.Dir, "hosts.yml"), []byte("version: '1'\nhosts: [\n"), 0o600))
require.Error(t, Execute(context.Background(), deps, []string{"config", "list"}))
require.NoError(t, Execute(context.Background(), deps, []string{"config", "path"}))
require.Equal(t, "config\t"+filepath.Join(deps.Settings.Dir, "config.yml")+"\nhosts\t"+filepath.Join(deps.Settings.Dir, "hosts.yml")+"\n", out.String())
}
func TestConfigSetReportsChangesOnATerminal(t *testing.T) {
deps, out, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")})
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "set", "markdown", "raw"}, "✓ Set markdown to raw\n"},
{[]string{"config", "set", "editor", ""}, "✓ Cleared editor\n"},
{[]string{"config", "set", "ssh_host", "git.example.org:2222", "--host", "example.org"}, "✓ Set ssh_host for https://example.org to git.example.org:2222\n"},
{[]string{"config", "set", "default_host", "https://example.org"}, "✓ Set default_host to https://example.org\n"},
{[]string{"config", "set", "default_host", "localhost:3002"}, "✓ Set default_host to https://localhost:3002\n! No account on https://localhost:3002 yet; log in with: tea auth login --host https://localhost:3002\n"},
} {
t.Run(strings.Join(tc.args[2:], " "), func(t *testing.T) {
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Equal(t, tc.want, withoutANSI(diagnostics.String()))
require.Empty(t, out.String())
})
}
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "raw", snapshot.Preferences.Markdown)
require.Equal(t, "git.example.org:2222", snapshot.Hosts.Hosts["https://example.org"].SSHHost)
}
func TestDisplayFollowsHyperlinkAndMarkdownPreferences(t *testing.T) {
for _, tc := range []struct {
config []string
links, rich bool
}{
{nil, true, true},
{[]string{"hyperlinks", "never"}, false, true},
{[]string{"markdown", "raw"}, true, false},
} {
deps, _, _ := testDeps(t)
deps.Streams.OutTTY, deps.Now = true, time.Now
for i := 0; i < len(tc.config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", tc.config[i], tc.config[i+1]}))
}
d := (&application{deps: deps}).display()
require.Equal(t, tc.links, d.Links, tc.config)
require.Equal(t, tc.rich, d.Markdown, tc.config)
}
}
func TestAliasSetGuardsExistingAliasesAndQuoting(t *testing.T) {
deps, _, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"alias", "set"}, args...))
}
require.NoError(t, run("il", "tea issue list --label bug"))
require.Equal(t, "✓ Added alias il for \"issue list --label bug\"\n", withoutANSI(diagnostics.String()))
err := run("il", "issue list")
require.ErrorContains(t, err, `alias il already expands to "issue list --label bug"; replace it with --clobber`)
diagnostics.Reset()
require.NoError(t, run("il", "issue list --state all", "--clobber"))
require.Equal(t, "✓ Changed alias il from \"issue list --label bug\" to \"issue list --state all\"\n", withoutANSI(diagnostics.String()))
err = run("ls", "issue", "list")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, `the expansion must be one argument; quote it: tea alias set ls "issue list"`)
err = run("il", "issue", "list", "--state", "all")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, `unknown flag: --state; quote an expansion with flags: tea alias set il "issue list --state all"`)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, map[string]string{"il": "issue list --state all"}, snapshot.Preferences.Aliases)
}
func TestAliasDeleteAndList(t *testing.T) {
deps, out, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"alias"}, args...))
}
require.NoError(t, run("list"))
require.Empty(t, out.String())
require.Equal(t, "no aliases configured; create one with: tea alias set <name> <expansion>\n", diagnostics.String())
for _, name := range []string{"a", "b", "c"} {
require.NoError(t, run("set", name, "issue list"))
}
diagnostics.Reset()
require.NoError(t, run("list"))
require.Equal(t, "NAME EXPANSION\na issue list\nb issue list\nc issue list\n", withoutANSI(out.String()))
err := run("delete", "a", "nope", "zz")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, `no alias named "nope", "zz"; tea alias list shows them`)
require.NoError(t, run("delete", "a", "b"))
require.Equal(t, "✓ Deleted alias a (issue list)\n✓ Deleted alias b (issue list)\n", withoutANSI(diagnostics.String()))
require.Equal(t, clierr.Usage, clierr.ExitCode(run("delete")))
require.Equal(t, clierr.Usage, clierr.ExitCode(run("delete", "c", "--all")))
require.NoError(t, run("delete", "--all"))
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Empty(t, snapshot.Preferences.Aliases)
}
+3 -4
View File
@@ -30,8 +30,7 @@ func (a *application) colorEnabled() bool {
return terminal.ColorEnabled(preferences.Color, a.deps.Streams.OutTTY, a.deps.Getenv)
}
// display describes stdout for tables and views. The hyperlinks (auto, always, never) and markdown
// (auto, raw) preferences are read from TEA_HYPERLINKS and TEA_MARKDOWN until config.yml stores them.
// display describes stdout for tables and views under the color, hyperlinks and markdown preferences.
func (a *application) display() terminal.Display {
preferences, _ := a.preferences()
streams := a.deps.Streams
@@ -39,8 +38,8 @@ func (a *application) display() terminal.Display {
TTY: streams.OutTTY,
Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv),
TrueColor: terminal.TrueColor(a.deps.Getenv),
Links: terminal.HyperlinksEnabled(a.deps.Getenv("TEA_HYPERLINKS"), streams.OutTTY, a.deps.Getenv),
Markdown: terminal.MarkdownEnabled(a.deps.Getenv("TEA_MARKDOWN"), streams.OutTTY),
Links: terminal.HyperlinksEnabled(preferences.Hyperlinks, streams.OutTTY, a.deps.Getenv),
Markdown: terminal.MarkdownEnabled(preferences.Markdown, streams.OutTTY),
Width: streams.Width,
Now: a.deps.Now(),
}
+8 -2
View File
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
@@ -51,6 +52,7 @@ func pagedDeps(t *testing.T, serverURL string, env map[string]string, config ...
for i := 0; i < len(config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]}))
}
diagnostics.Reset()
return deps, out, diagnostics, pager
}
@@ -174,7 +176,7 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"TEA_HYPERLINKS": "never"}, "pager", "fake-pager", "color", "never")
deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never", "hyperlinks", "never")
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes")))
require.Equal(t, 1, pager.calls)
require.Equal(t, tc.want, pager.input)
@@ -341,7 +343,11 @@ func TestWriteView(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
deps, out, _ := testDeps(t)
deps.Streams.OutTTY = tc.tty
deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"})
deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1"})
require.NoError(t, deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
s.Preferences.Markdown, s.Preferences.Hyperlinks = "raw", "never"
return nil
}))
a, cmd := outputApplication(deps, tc.json)
require.NoError(t, writeView(a, cmd, item, view))
require.Equal(t, tc.want, out.String())
+15 -1
View File
@@ -52,6 +52,8 @@ type Preferences struct {
Pager string `yaml:"pager,omitempty" json:"pager"`
Browser string `yaml:"browser,omitempty" json:"browser"`
Color string `yaml:"color,omitempty" json:"color"`
Hyperlinks string `yaml:"hyperlinks,omitempty" json:"hyperlinks"`
Markdown string `yaml:"markdown,omitempty" json:"markdown"`
Remote string `yaml:"remote,omitempty" json:"remote"`
Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"`
}
@@ -114,7 +116,7 @@ func New(dir string) *Store {
// Defaults returns a fresh preferences value with built-in settings.
func Defaults() Preferences {
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Aliases: map[string]string{}}
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Hyperlinks: "auto", Markdown: "auto", Aliases: map[string]string{}}
}
// stored omits built-in defaults so that only explicit settings are persisted.
@@ -129,6 +131,12 @@ func (p Preferences) stored() Preferences {
if p.Color == defaults.Color {
p.Color = ""
}
if p.Hyperlinks == defaults.Hyperlinks {
p.Hyperlinks = ""
}
if p.Markdown == defaults.Markdown {
p.Markdown = ""
}
return p
}
@@ -247,6 +255,12 @@ func (s Snapshot) Validate() error {
if p.Color != "auto" && p.Color != "always" && p.Color != "never" {
return errors.New("color must be auto, always, or never")
}
if p.Hyperlinks != "auto" && p.Hyperlinks != "always" && p.Hyperlinks != "never" {
return errors.New("hyperlinks must be auto, always, or never")
}
if p.Markdown != "auto" && p.Markdown != "raw" {
return errors.New("markdown must be auto or raw")
}
if p.DefaultHost != "" {
normalized, err := NormalizeHost(p.DefaultHost)
if err != nil || normalized != p.DefaultHost {
+20 -1
View File
@@ -120,7 +120,7 @@ func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
snapshot, err := s.Load()
require.NoError(t, err)
require.True(t, snapshot.Configured["editor"])
for _, key := range []string{"git_protocol", "prompt", "color", "aliases"} {
for _, key := range []string{"git_protocol", "prompt", "color", "hyperlinks", "markdown", "aliases"} {
require.False(t, snapshot.Configured[key], key)
}
require.Equal(t, Defaults().GitProtocol, snapshot.Preferences.GitProtocol)
@@ -130,6 +130,25 @@ func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
require.Equal(t, "version: \"1\"\neditor: vi\ncolor: never\n", string(data))
}
func TestDisplayPreferencesAcceptOnlyTheirValues(t *testing.T) {
for _, tc := range []struct {
config string
valid bool
}{
{"hyperlinks: never\n", true},
{"hyperlinks: always\nmarkdown: raw\n", true},
{"hyperlinks: off\n", false},
{"markdown: never\n", false},
} {
t.Run(tc.config, func(t *testing.T) {
s := New(t.TempDir())
require.NoError(t, os.WriteFile(filepath.Join(s.Dir, "config.yml"), []byte("version: '1'\n"+tc.config), 0o600))
_, err := s.Load()
require.Equal(t, tc.valid, err == nil, "%v", err)
})
}
}
func TestTokenlessAccounts(t *testing.T) {
for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} {
require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method)