Try branch color patterns in the order they are written

If several patterns in gui.branchColorPatterns match a branch, the color
it gets is picked at random, and it can change from one render to the
next. The patterns are kept in a Go map, and Go randomizes the order in
which a map is iterated.

Keep the patterns in a list instead, in the order in which they are
written, and let the first match win. If a repo's config file has
patterns too, put them in front of those of the global config file,
because they are more specific.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 08:16:06 +02:00
co-authored by Claude Opus 5.5
parent ae1b43dc96
commit 8e396228e9
7 changed files with 177 additions and 24 deletions
+2
View File
@@ -1069,6 +1069,8 @@ gui:
Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed.
If several patterns match a branch, the first one wins.
## Custom Files Icon & Color
You can customize the icon and color of files based on filenames or extensions:
+69
View File
@@ -0,0 +1,69 @@
package config
import (
"slices"
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
// ColorPatterns assigns colors to the names that match regular expressions.
// It's written in YAML as a mapping from pattern to color, and keeps the
// patterns in the order in which they are written, so that the first pattern
// that matches a name can decide its color.
type ColorPatterns []ColorPattern
type ColorPattern struct {
Pattern string
Color string
}
// UnmarshalYAML puts the patterns it reads in front of the ones that are there
// already, which come from config files that were loaded earlier.
func (p *ColorPatterns) UnmarshalYAML(node *yaml.Node) error {
// Decoding into a map reports malformed input the same way as for the
// other maps in the config.
var colors map[string]string
if err := node.Decode(&colors); err != nil {
return err
}
patterns := make(ColorPatterns, 0, len(colors))
for i := 0; i < len(node.Content)-1; i += 2 {
var pattern string
if err := node.Content[i].Decode(&pattern); err != nil {
return err
}
patterns = append(patterns, ColorPattern{Pattern: pattern, Color: colors[pattern]})
}
*p = patterns.over(*p)
return nil
}
func (p ColorPatterns) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
for _, pattern := range p {
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Pattern},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Color},
)
}
return node, nil
}
// JSONSchema describes the patterns as the mapping they are written as.
func (ColorPatterns) JSONSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Type: "string"},
}
}
// over returns p followed by the patterns of lower that p doesn't have.
func (p ColorPatterns) over(lower ColorPatterns) ColorPatterns {
return slices.Concat(p, lo.Reject(lower, func(l ColorPattern, _ int) bool {
return slices.ContainsFunc(p, func(c ColorPattern) bool { return c.Pattern == l.Pattern })
}))
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
type colorPatternsConfig struct {
Patterns ColorPatterns `yaml:"patterns"`
}
func TestColorPatternsKeepTheirOrder(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^b': red\n"+
" '^a': '#00ff00'\n"+
" '^c': blue\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "^c", Color: "blue"},
}, config.Patterns)
}
func TestColorPatternsOfALaterFileComeFirst(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^a': red\n"+
" '^b': green\n"), &config)
assert.NoError(t, err)
err = yaml.Unmarshal([]byte("patterns:\n"+
" '^c': blue\n"+
" '^b': yellow\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^c", Color: "blue"},
{Pattern: "^b", Color: "yellow"},
{Pattern: "^a", Color: "red"},
}, config.Patterns)
}
func TestColorPatternsMustBeAMapping(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns: 5\n"), &config)
assert.ErrorContains(t, err, "cannot unmarshal !!int `5` into map[string]string")
}
func TestColorPatternsSurviveMarshalling(t *testing.T) {
config := colorPatternsConfig{Patterns: ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "true", Color: "blue"},
}}
content, err := yaml.Marshal(config)
assert.NoError(t, err)
var roundTripped colorPatternsConfig
err = yaml.Unmarshal(content, &roundTripped)
assert.NoError(t, err)
assert.Equal(t, config, roundTripped)
}
+1 -1
View File
@@ -71,7 +71,7 @@ type GuiConfig struct {
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
BranchColorPatterns map[string]string `yaml:"branchColorPatterns"`
BranchColorPatterns ColorPatterns `yaml:"branchColorPatterns"`
// Custom icons for filenames and file extensions
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
CustomIcons CustomIconsConfig `yaml:"customIcons"`
+12 -19
View File
@@ -19,11 +19,12 @@ import (
"github.com/samber/lo"
)
type colorMatcher struct {
patterns map[string]*style.TextStyle
type branchColorPattern struct {
pattern string
style style.TextStyle
}
var colorPatterns *colorMatcher
var branchColorPatterns []branchColorPattern
func GetBranchListDisplayStrings(
branches []*models.Branch,
@@ -192,21 +193,13 @@ func getBranchDisplayStrings(
// GetBranchTextStyle branch color
func GetBranchTextStyle(name string) style.TextStyle {
if style, ok := colorPatterns.match(name); ok {
return *style
}
return theme.DefaultTextColor
}
func (m *colorMatcher) match(name string) (*style.TextStyle, bool) {
for pattern, style := range m.patterns {
if matched, _ := regexp.MatchString(pattern, name); matched {
return style, true
for _, p := range branchColorPatterns {
if matched, _ := regexp.MatchString(p.pattern, name); matched {
return p.style
}
}
return nil, false
return theme.DefaultTextColor
}
func BranchStatus(
@@ -262,10 +255,10 @@ func divergenceStr(
return result
}
func SetCustomBranches(customBranchColors map[string]string) {
colorPatterns = &colorMatcher{
patterns: utils.SetCustomColors(customBranchColors),
}
func SetCustomBranches(patterns config.ColorPatterns) {
branchColorPatterns = lo.Map(patterns, func(p config.ColorPattern, _ int) branchColorPattern {
return branchColorPattern{pattern: p.Pattern, style: utils.CustomColorStyle(p.Color)}
})
}
func WithPrColor(state string, text string, isBg bool) string {
+21
View File
@@ -2,6 +2,7 @@ package presentation
import (
"fmt"
"slices"
"sync/atomic"
"testing"
"time"
@@ -9,10 +10,12 @@ import (
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
@@ -436,3 +439,21 @@ func Test_getBranchDisplayStrings(t *testing.T) {
})
}
}
func TestGetBranchTextStyle(t *testing.T) {
defer SetCustomBranches(nil)
patterns := config.ColorPatterns{
{Pattern: "^feature/", Color: "green"},
{Pattern: `ISSUE-\d+`, Color: "red"},
}
SetCustomBranches(patterns)
assert.Equal(t, style.FgGreen, GetBranchTextStyle("feature/ISSUE-1"))
assert.Equal(t, style.FgRed, GetBranchTextStyle("fix/ISSUE-1"))
assert.Equal(t, theme.DefaultTextColor, GetBranchTextStyle("main"))
slices.Reverse(patterns)
SetCustomBranches(patterns)
assert.Equal(t, style.FgRed, GetBranchTextStyle("feature/ISSUE-1"))
}
+8 -4
View File
@@ -3,6 +3,13 @@
"$id": "https://github.com/jesseduffield/lazygit/pkg/config/user-config",
"$ref": "#/$defs/UserConfig",
"$defs": {
"ColorPatterns": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color"
},
"CommitConfig": {
"properties": {
"signOff": {
@@ -538,10 +545,7 @@
"description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color"
},
"branchColorPatterns": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"$ref": "#/$defs/ColorPatterns",
"description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color"
},
"customIcons": {