jesseduffield.lazygit/pkg/config/user_config_validation.go
Stefan Haller 81420ce362 Reject pager entries that combine multiple diff mechanisms
A pager (GIT_PAGER) formats the diff git produces, while
externalDiffCommand and useExternalDiffGitConfig change how git produces
the diff in the first place. They are different pipeline stages, not
alternatives, so combining them on one entry just pipes one through the
other and produces garbled output (e.g. delta trying to parse
difftastic's side-by-side output as a unified diff). The two external
mechanisms likewise conflict, with the explicit command silently
shadowing the git config one. Treat all three as mutually exclusive and
reject configs that set more than one on the same entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:51:56 +02:00

218 lines
6.6 KiB
Go

package config
import (
"errors"
"fmt"
"log"
"reflect"
"slices"
"strings"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
func (config *UserConfig) Validate() error {
if err := validateEnum("gui.statusPanelView", config.Gui.StatusPanelView,
[]string{"dashboard", "allBranchesLog"}); err != nil {
return err
}
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err
}
if err := validateEnum("gui.fileTreeSortOrder", config.Gui.FileTreeSortOrder,
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
return err
}
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
return err
}
if err := validateEnum("git.localBranchSortOrder", config.Git.LocalBranchSortOrder,
[]string{"date", "recency", "alphabetical"}); err != nil {
return err
}
if err := validateEnum("git.remoteBranchSortOrder", config.Git.RemoteBranchSortOrder,
[]string{"date", "alphabetical"}); err != nil {
return err
}
if err := validateEnum("git.log.order", config.Git.Log.Order,
[]string{"date-order", "author-date-order", "topo-order", "default"}); err != nil {
return err
}
if err := validateEnum("git.log.showGraph", config.Git.Log.ShowGraph,
[]string{"always", "never", "when-maximised"}); err != nil {
return err
}
if err := validatePagers(config.Git.Pagers); err != nil {
return err
}
if err := validateKeybindings(config.Keybinding); err != nil {
return err
}
if err := validateCustomCommands(config.CustomCommands); err != nil {
return err
}
if err := validateSpinner(config.Gui.Spinner); err != nil {
return err
}
return nil
}
func validateSpinner(spinner SpinnerConfig) error {
if len(spinner.Frames) == 0 {
return errors.New("gui.spinner.frames must not be empty.")
}
firstWidth := utils.StringWidth(spinner.Frames[0])
if lo.SomeBy(spinner.Frames, func(frame string) bool {
return utils.StringWidth(frame) != firstWidth
}) {
return errors.New("All gui.spinner.frames entries must have the same width.")
}
return nil
}
// validatePagers rejects pager entries that combine more than one diff
// mechanism. A pager (GIT_PAGER) formats the diff that git produces, whereas
// externalDiffCommand and useExternalDiffGitConfig change how git produces the
// diff in the first place; piping one through the other almost always yields
// garbled output, so we treat the three as mutually exclusive.
func validatePagers(pagers []PagingConfig) error {
for i, pager := range pagers {
count := 0
if pager.Pager != "" {
count++
}
if pager.ExternalDiffCommand != "" {
count++
}
if pager.UseExternalDiffGitConfig {
count++
}
if count > 1 {
return fmt.Errorf("git.pagers[%d]: at most one of 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' may be set; they are mutually exclusive", i)
}
}
return nil
}
func validateEnum(name string, value string, allowedValues []string) error {
if slices.Contains(allowedValues, value) {
return nil
}
allowedValuesStr := strings.Join(allowedValues, ", ")
return fmt.Errorf("Unexpected value '%s' for '%s'. Allowed values: %s", value, name, allowedValuesStr)
}
func validateKeybindingsRecurse(path string, node any) error {
value := reflect.ValueOf(node)
if value.Kind() == reflect.Struct {
for _, field := range reflect.VisibleFields(reflect.TypeOf(node)) {
var newPath string
if len(path) == 0 {
newPath = field.Name
} else {
newPath = fmt.Sprintf("%s.%s", path, field.Name)
}
if err := validateKeybindingsRecurse(newPath,
value.FieldByName(field.Name).Interface()); err != nil {
return err
}
}
} else if value.Kind() == reflect.Slice {
for i := range value.Len() {
if err := validateKeybindingsRecurse(
fmt.Sprintf("%s[%d]", path, i), value.Index(i).Interface()); err != nil {
return err
}
}
} else if value.Kind() == reflect.String {
key := node.(string)
if !isValidKeybindingKey(key) {
return fmt.Errorf("Unrecognized key '%s' for keybinding '%s'. For permitted values see %s",
key, path, constants.Links.Docs.CustomKeybindings)
}
} else {
log.Fatalf("Unexpected type for property '%s': %s", path, value.Kind())
}
return nil
}
func validateKeybindings(keybindingConfig KeybindingConfig) error {
if err := validateKeybindingsRecurse("", keybindingConfig); err != nil {
return err
}
if len(keybindingConfig.Universal.JumpToBlock) != 5 {
return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.",
len(keybindingConfig.Universal.JumpToBlock))
}
return nil
}
func validateCustomCommandKey(key Keybinding) error {
for _, k := range key {
if !isValidKeybindingKey(k) {
return fmt.Errorf("Unrecognized key '%s' for custom command. For permitted values see %s",
k, constants.Links.Docs.CustomKeybindings)
}
}
return nil
}
func validateCustomCommands(customCommands []CustomCommand) error {
for _, customCommand := range customCommands {
if err := validateCustomCommandKey(customCommand.Key); err != nil {
return err
}
if len(customCommand.CommandMenu) > 0 {
if len(customCommand.Context) > 0 ||
len(customCommand.Command) > 0 ||
len(customCommand.Prompts) > 0 ||
len(customCommand.LoadingText) > 0 ||
len(customCommand.Output) > 0 ||
len(customCommand.OutputTitle) > 0 ||
customCommand.After != nil {
commandRef := ""
if len(customCommand.Key) > 0 {
commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key.String())
}
return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef)
}
if err := validateCustomCommands(customCommand.CommandMenu); err != nil {
return err
}
} else {
for _, prompt := range customCommand.Prompts {
if err := validateCustomCommandPrompt(prompt); err != nil {
return err
}
}
if err := validateEnum("customCommand.output", customCommand.Output,
[]string{"", "none", "terminal", "log", "logWithPty", "popup"}); err != nil {
return err
}
}
}
return nil
}
func validateCustomCommandPrompt(prompt CustomCommandPrompt) error {
for _, option := range prompt.Options {
for _, k := range option.Key {
if !isValidKeybindingKey(k) {
return fmt.Errorf("Unrecognized key '%s' for custom command prompt option. For permitted values see %s",
k, constants.Links.Docs.CustomKeybindings)
}
}
}
return nil
}