[SQUASHED] draw-graph-with-branch-drawing-symbols

This commit is contained in:
Stefan Haller
2026-09-30 09:01:00 +02:00
parent 5b94384de1
commit 39c08eddbc
16 changed files with 679 additions and 44 deletions
+12
View File
@@ -300,6 +300,18 @@ gui:
# NerdFontsVersion is not empty.
showFileIcons: true
# How the commit graph is drawn.
# One of: 'auto' (default) | 'classic' | 'detailed'
# 'detailed' connects the lines to the commit circles, and shows exactly where
# branches fork off and merge. It draws the graph with the git branch drawing
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
# GPU acceleration. Other terminals need a font that contains them, such as
# https://github.com/rbong/flog-symbols.
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
# these symbols (kitty and Ghostty), and 'classic' otherwise.
commitGraphStyle: auto
# Length of author name in (non-expanded) commits view. 2 means show initials
# only.
commitAuthorShortLength: 2
+6
View File
@@ -179,6 +179,11 @@ type GuiConfig struct {
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
ShowFileIcons bool `yaml:"showFileIcons"`
// How the commit graph is drawn.
// One of: 'auto' (default) | 'classic' | 'detailed'
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"`
// Length of author name in (non-expanded) commits view. 2 means show initials only.
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
// Length of author name in expanded commits view. 2 means show initials only.
@@ -925,6 +930,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
ShowIcons: false,
NerdFontsVersion: "",
ShowFileIcons: true,
CommitGraphStyle: "auto",
CommitAuthorShortLength: 2,
CommitAuthorLongLength: 17,
CommitHashLength: 8,
+4
View File
@@ -30,6 +30,10 @@ func (config *UserConfig) Validate() error {
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
return err
}
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
[]string{"auto", "classic", "detailed"}); err != nil {
return err
}
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
return err
+6
View File
@@ -743,6 +743,12 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme {
return g.colorScheme
}
// Terminal returns the name and version that the terminal reported at
// startup. Both are empty if it didn't report them.
func (g *Gui) Terminal() (string, string) {
return g.screen.Terminal()
}
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
// the terminal's colors change after startup.
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
+14
View File
@@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
@@ -132,6 +133,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
startIdx,
endIdx,
shouldShowGraph(c),
commitGraphSymbolSet(c),
c.Model().BisectInfo,
)
}
@@ -408,6 +410,18 @@ func shouldShowGraph(c *ContextCommon) bool {
return false
}
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
switch c.UserConfig().Gui.CommitGraphStyle {
case "detailed":
return graph.BranchDrawingSymbols
case "auto":
if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) {
return graph.BranchDrawingSymbols
}
}
return graph.BoxDrawingSymbols
}
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
modelToViewIndex func(int) int, searchStr string,
) []gocui.SearchPosition {
+1
View File
@@ -97,6 +97,7 @@ func NewSubCommitsContext(
startIdx,
endIdx,
shouldShowGraph(c),
commitGraphSymbolSet(c),
git_commands.NewNullBisectInfo(),
)
}
+2
View File
@@ -953,6 +953,8 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
g.ErrorHandler = gui.PopupHandler.ErrorHandler
terminalName, terminalVersion := g.Terminal()
gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion)
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
+4
View File
@@ -59,6 +59,7 @@ func GetCommitListDisplayStrings(
startIdx int,
endIdx int,
showGraph bool,
graphSymbolSet graph.SymbolSet,
bisectInfo *git_commands.BisectInfo,
) [][]string {
mutex.Lock()
@@ -107,6 +108,7 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
allGraphLines = append(allGraphLines, graphLines...)
}
@@ -124,6 +126,7 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
allGraphLines = append(allGraphLines, graphLines...)
}
@@ -145,6 +148,7 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
getGraphLine = func(idx int) string {
if idx >= graphOffset {
+2 -1
View File
@@ -611,6 +611,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
s.startIdx,
s.endIdx,
s.showGraph,
graph.BoxDrawingSymbols,
s.bisectInfo,
)
@@ -635,7 +636,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
}
renderGraph := func() string {
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n")
}
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
@@ -0,0 +1,132 @@
package graph
import (
"slices"
"strconv"
"strings"
)
// The branch drawing symbols are characters in the Unicode Private Use Area
// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in
// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing
// characters, they can show how the lines in a cell connect. For example, there
// is a symbol for a cell in which a line from above bends to the left and a
// line from the left bends down.
const branchDrawingHorizontal = "\uf5d0"
// The lines in a cell that isn't a commit
type lineCourses struct {
up, down verticalLine
horizontal bool
}
var branchDrawingConnections = map[lineCourses]string{
{}: " ",
{horizontal: true}: branchDrawingHorizontal,
// A horizontal line passing behind a vertical one only shows in the
// neighbouring cells
{up: straightLine, down: straightLine}: "\uf5d1", // │
{up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │
{up: lineToLeft}: "\uf5d9", // ╯
{up: lineToRight}: "\uf5d8", // ╰
{down: lineToLeft}: "\uf5d7", // ╮
{down: lineToRight}: "\uf5d6", // ╭
{up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─
{up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─
{down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─
{down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─
{up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮
{up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭
{up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─
{up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─
{up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─
{up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─
}
// The edges of a commit's cell that its lines touch
type commitEdges struct {
up, down, left, right bool
}
// Commits are drawn as hollow circles, merge commits as filled ones. Both
// connect to the lines at the given edges.
var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{
{}: {"\uf5ef", "\uf5ee"},
{right: true}: {"\uf5f1", "\uf5f0"},
{left: true}: {"\uf5f3", "\uf5f2"},
{left: true, right: true}: {"\uf5f5", "\uf5f4"},
{down: true}: {"\uf5f7", "\uf5f6"},
{up: true}: {"\uf5f9", "\uf5f8"},
{up: true, down: true}: {"\uf5fb", "\uf5fa"},
{down: true, right: true}: {"\uf5fd", "\uf5fc"},
{down: true, left: true}: {"\uf5ff", "\uf5fe"},
{up: true, right: true}: {"\uf601", "\uf600"},
{up: true, left: true}: {"\uf603", "\uf602"},
{up: true, down: true, right: true}: {"\uf605", "\uf604"},
{up: true, down: true, left: true}: {"\uf607", "\uf606"},
{down: true, left: true, right: true}: {"\uf609", "\uf608"},
{up: true, left: true, right: true}: {"\uf60b", "\uf60a"},
{up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"},
}
func (cell *Cell) branchDrawingChars() (string, string) {
second := " "
if cell.right {
second = branchDrawingHorizontal
}
switch cell.cellType {
case COMMIT, MERGE:
symbols := branchDrawingCommitSymbols[commitEdges{
up: cell.up != noLine,
down: cell.down != noLine,
left: cell.left,
right: cell.right,
}]
if cell.cellType == MERGE {
return symbols.merge, second
}
return symbols.commit, second
default:
if cell.horizontalOnTop {
return branchDrawingHorizontal, second
}
if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok {
return first, second
}
// There is no symbol for lines that the layout of the graph never
// produces, such as a lone half of a line
first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
return first, second
}
}
// The terminals that draw the branch drawing symbols themselves, with the
// first version that draws all of them
var terminalsWithBranchDrawingSymbols = map[string][]int{
"kitty": {0, 36, 2},
"ghostty": {1, 0, 0},
}
// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw
// the branch drawing symbols itself, given the name and version it reports
func TerminalDrawsBranchDrawingSymbols(name, version string) bool {
minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)]
return ok && slices.Compare(versionNumbers(version), minVersion) >= 0
}
// The numbers at the start of the dot-separated parts of a version, e.g.
// [1 3 0] for "1.3.0-dev+abc"
func versionNumbers(version string) []int {
var numbers []int
for part := range strings.SplitSeq(version, ".") {
digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))]
number, err := strconv.Atoi(digits)
if err != nil {
break
}
numbers = append(numbers, number)
}
return numbers
}
@@ -0,0 +1,33 @@
package graph
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) {
tests := []struct {
name string
version string
expected bool
}{
{name: "kitty", version: "0.36.1", expected: false},
{name: "kitty", version: "0.36.2", expected: true},
{name: "kitty", version: "0.44.0", expected: true},
{name: "ghostty", version: "0.9.0", expected: false},
{name: "ghostty", version: "1.0.0", expected: true},
{name: "ghostty", version: "1.3.0-main+0123abcd", expected: true},
{name: "WezTerm", version: "20250601-102030-89abcdef", expected: false},
{name: "tmux", version: "3.5a", expected: false},
{name: "iTerm2", version: "3.6.4", expected: false},
{name: "kitty", version: "", expected: false},
{name: "", version: "", expected: false},
}
for _, test := range tests {
t.Run(test.name+" "+test.version, func(t *testing.T) {
assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version))
})
}
}
+66 -25
View File
@@ -13,6 +13,14 @@ const (
CommitSymbol = '○'
)
// The characters that the commit graph is drawn with
type SymbolSet uint8
const (
BoxDrawingSymbols SymbolSet = iota
BranchDrawingSymbols
)
type cellType int
const (
@@ -21,25 +29,40 @@ const (
MERGE
)
// How a line that touches the top or bottom edge of a cell runs within it
type verticalLine uint8
const (
noLine verticalLine = iota
// On to the opposite edge, or into the commit symbol
straightLine
// Bends towards the left edge
lineToLeft
// Bends towards the right edge
lineToRight
)
type Cell struct {
up, down, left, right bool
cellType cellType
rightStyle *style.TextStyle
style *style.TextStyle
// The lines that touch the top and bottom edges
up, down verticalLine
// Whether lines touch the left and right edges
left, right bool
// Whether a line passes through from the left edge to the right edge
horizontal bool
// Whether that line is drawn over the vertical line that it crosses
horizontalOnTop bool
cellType cellType
rightStyle *style.TextStyle
style *style.TextStyle
}
func (cell *Cell) render(writer io.StringWriter) {
up, down, left, right := cell.up, cell.down, cell.left, cell.right
first, second := getBoxDrawingChars(up, down, left, right)
var adjustedFirst string
switch cell.cellType {
case CONNECTION:
adjustedFirst = first
case COMMIT:
adjustedFirst = string(CommitSymbol)
case MERGE:
adjustedFirst = string(MergeSymbol)
func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
var first, second string
switch symbolSet {
case BoxDrawingSymbols:
first, second = cell.boxDrawingChars()
case BranchDrawingSymbols:
first, second = cell.branchDrawingChars()
}
var rightStyle *style.TextStyle
@@ -59,10 +82,22 @@ func (cell *Cell) render(writer io.StringWriter) {
styledSecondChar = cachedSprint(*rightStyle, second)
}
_, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst))
_, _ = writer.WriteString(cachedSprint(*cell.style, first))
_, _ = writer.WriteString(styledSecondChar)
}
func (cell *Cell) boxDrawingChars() (string, string) {
first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
switch cell.cellType {
case COMMIT:
return string(CommitSymbol), second
case MERGE:
return string(MergeSymbol), second
default:
return first, second
}
}
type rgbCacheKey struct {
*color.RGBStyle
str string
@@ -98,34 +133,35 @@ func cachedSprint(style style.TextStyle, str string) string {
}
func (cell *Cell) reset() {
cell.up = false
cell.down = false
cell.up = noLine
cell.down = noLine
cell.left = false
cell.right = false
cell.horizontal = false
cell.horizontalOnTop = false
}
func (cell *Cell) setUp(style *style.TextStyle) *Cell {
cell.up = true
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
cell.up = line
cell.style = style
return cell
}
func (cell *Cell) setDown(style *style.TextStyle) *Cell {
cell.down = true
func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell {
cell.down = line
cell.style = style
return cell
}
func (cell *Cell) setLeft(style *style.TextStyle) *Cell {
cell.left = true
if !cell.up && !cell.down {
if cell.up == noLine && cell.down == noLine {
// vertical trumps left
cell.style = style
}
return cell
}
//nolint:unparam
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
cell.right = true
if cell.rightStyle == nil || override {
@@ -134,6 +170,11 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
return cell
}
func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell {
cell.horizontal = true
return cell.setLeft(style).setRight(style, overrideRightStyle)
}
func (cell *Cell) setStyle(style *style.TextStyle) *Cell {
cell.style = style
return cell
+60 -13
View File
@@ -45,13 +45,13 @@ func (self Pipe) right() int16 {
return max(self.fromPos, self.toPos)
}
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string {
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string {
pipeSets := GetPipeSets(commits, getStyle)
if len(pipeSets) == 0 {
return nil
}
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr)
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet)
return lines
}
@@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl
})
}
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string {
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string {
maxProcs := runtime.GOMAXPROCS(0)
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
@@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
if k > 0 {
prevCommit = commits[k-1]
}
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit)
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet)
innerLines = append(innerLines, line)
}
chunks[i] = innerLines
@@ -276,6 +276,7 @@ func renderPipeSet(
pipes []Pipe,
selectedCommitHashPtr *string,
prevCommit *models.Commit,
symbolSet SymbolSet,
) string {
maxPos := int16(0)
commitPos := int16(0)
@@ -304,17 +305,17 @@ func renderPipeSet(
if left != right {
for i := left + 1; i < right; i++ {
cells[i].setLeft(style).setRight(style, overrideRightStyle)
cells[i].setHorizontal(style, overrideRightStyle)
}
cells[left].setRight(style, overrideRightStyle)
cells[right].setLeft(style)
}
if pipe.kind == STARTS || pipe.kind == CONTINUES {
cells[pipe.toPos].setDown(style)
cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos))
}
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
cells[pipe.fromPos].setUp(style)
cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos))
}
}
@@ -343,14 +344,31 @@ func renderPipeSet(
}
for _, pipe := range nonSelectedPipes {
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
renderPipe(&pipe, pipe.style, false)
if pipe.kind == STARTS {
continue
}
if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos {
// Record this line without drawing it, so that the commit symbol
// keeps the style of the commit itself. The branch drawing commit
// symbols connect to it. The pipe from above the first commit
// doesn't come from a commit, so it has no line.
if !equalHashes(pipe.fromHash, &StartCommitHash) {
cells[commitPos].up = straightLine
}
continue
}
renderPipe(&pipe, pipe.style, false)
}
for _, pipe := range selectedPipes {
for i := pipe.left(); i <= pipe.right(); i++ {
cells[i].reset()
// Box drawing characters can't show every combination of lines in a cell,
// so the cells that the selected commit's lines run through show only
// those lines. The branch drawing symbols can, so they keep the other
// lines too.
if symbolSet == BoxDrawingSymbols {
for _, pipe := range selectedPipes {
for i := pipe.left(); i <= pipe.right(); i++ {
cells[i].reset()
}
}
}
for _, pipe := range selectedPipes {
@@ -360,6 +378,22 @@ func renderPipeSet(
}
}
// Where a line of the selected commit crosses the vertical line of another
// commit, draw it over that line, so that it reads as one line. The
// vertical line visibly continues above and below. A vertical line in a
// cell that doesn't have the highlight style belongs to another commit.
if symbolSet == BranchDrawingSymbols {
for _, pipe := range selectedPipes {
for i := pipe.left() + 1; i < pipe.right(); i++ {
cell := cells[i]
if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle {
cell.horizontalOnTop = true
cell.style = &highlightStyle
}
}
}
}
cType := COMMIT
if isMerge {
cType = MERGE
@@ -371,11 +405,24 @@ func renderPipeSet(
writer := &strings.Builder{}
writer.Grow(len(cells) * 2)
for _, cell := range cells {
cell.render(writer)
cell.render(writer, symbolSet)
}
return writer.String()
}
// The course of a line through the top or bottom edge of the cell at pos,
// given the column of the pipe's other end
func lineTowards(pos, otherPos int16) verticalLine {
switch {
case otherPos < pos:
return lineToLeft
case otherPos > pos:
return lineToRight
default:
return straightLine
}
}
func equalHashes(a, b *string) bool {
// if our selectedCommitHashPtr is nil, there is no selected commit
if a == nil || b == nil {
+324 -5
View File
@@ -224,7 +224,7 @@ func TestRenderCommitGraph(t *testing.T) {
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
commits := lo.Map(test.commitOpts,
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle)
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols)
trimmedExpectedOutput := ""
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
@@ -247,6 +247,325 @@ func TestRenderCommitGraph(t *testing.T) {
}
}
// Box drawing look-alikes for the branch drawing symbols, so that the expected
// output in tests is readable. The box drawing characters of the graph never
// use ┤ and ┼; here they stand for the symbols that have two bends. Commit
// symbols are shown as ○ and ◎, whichever lines they connect to.
var branchDrawingLookAlikes = func() map[rune]rune {
lookAlikes := map[rune]rune{
'\uf5d0': '─',
'\uf5d1': '│',
'\uf5d6': '╭',
'\uf5d7': '╮',
'\uf5d8': '╰',
'\uf5d9': '╯',
'\uf5e0': '┬', // ╮ on ─
'\uf5e3': '┴', // ╯ on ─
'\uf5df': '┤', // ╯ and ╮
'\uf5e8': '┼', // ╯ and ╮ on ─
}
for _, symbols := range branchDrawingCommitSymbols {
lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol
lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol
}
return lookAlikes
}()
func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) {
tests := []struct {
name string
commitOpts []models.NewCommitOpts
selectedHash string
expectedOutput string
}{
{
name: "branch forked off a merge commit",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─│─╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with the merge commit selected",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
selectedHash: "5",
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─│─╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with the merge commit above it selected",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
selectedHash: "2",
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎───╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with no line passing through",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"2"}},
{Hash: "2", Parents: []string{"4", "5"}},
{Hash: "4", Parents: []string{"6", "7"}},
{Hash: "6", Parents: []string{"8"}},
},
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─┤
4 ◎─│─╮
6 ○ │ │`,
},
{
name: "several lines ending in a commit",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2"}},
{Hash: "2", Parents: []string{"3", "4"}},
{Hash: "3", Parents: []string{"5", "4"}},
{Hash: "5", Parents: []string{"7", "8"}},
{Hash: "7", Parents: []string{"4", "A"}},
{Hash: "4", Parents: []string{"B"}},
{Hash: "B", Parents: []string{"C"}},
},
expectedOutput: `
1 ○
2 ◎─╮
3 ◎─│─╮
5 ◎─│─│─╮
7 ◎─│─│─│─╮
4 ○─┴─╯ │ │
B ○ ╭───╯ │`,
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
hashPool := &utils.StringPool{}
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
commits := lo.Map(test.commitOpts,
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols)
trimmedExpectedOutput := ""
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
trimmedExpectedOutput += strings.TrimSpace(line) + "\n"
}
output := ""
for i, line := range lines {
lookAlikes := strings.Map(func(r rune) rune {
return lo.ValueOr(branchDrawingLookAlikes, r, r)
}, utils.Decolorise(line))
output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n"
}
assert.Equal(t, trimmedExpectedOutput, output)
})
}
}
func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) {
cyan := style.FgCyan
red := style.FgRed
green := style.FgGreen
yellow := style.FgYellow
magenta := style.FgMagenta
nothing := style.Nothing
hashPool := &utils.StringPool{}
pool := func(s string) *string { return hashPool.Add(s) }
tests := []struct {
name string
pipes []Pipe
prevCommit *models.Commit
expectedStr string
expectedStyles []style.TextStyle
}{
{
name: "commit with lines above and below",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
expectedStr: "\uf5fb", // ○ with lines up and down
expectedStyles: []style.TextStyle{green},
},
{
name: "first commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
},
expectedStr: "\uf5f7", // ○ with a line down
expectedStyles: []style.TextStyle{green},
},
{
name: "selected commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
expectedStr: "\uf5fb", // ○ with lines up and down
expectedStyles: []style.TextStyle{highlightStyle},
},
{
name: "commit whose previous commit is selected and is a merge commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}),
expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │
expectedStyles: []style.TextStyle{
highlightStyle, nothing, highlightStyle,
},
},
{
name: "merge commit that a branch forks off",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
green, green, magenta, cyan, cyan,
},
},
{
name: "merge commit that a branch forks off, with the branch's first commit selected",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, highlightStyle, cyan, cyan,
},
},
{
name: "line of the selected commit crossing another line",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red},
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow},
{fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta},
{fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green},
{fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green,
},
},
{
name: "line of the selected commit passing through a cell in which other lines bend",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle,
},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols)
if len([]rune(test.expectedStr)) != len(test.expectedStyles) {
t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles))
}
expectedStr := ""
for i, char := range []rune(test.expectedStr) {
expectedStr += test.expectedStyles[i].Sprint(string(char))
}
expectedStr += " "
assert.Equal(t, expectedStr, actualStr)
})
}
}
func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) {
hashPool := &utils.StringPool{}
commits := generateCommits(hashPool, 1000)
getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault }
isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' }
for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} {
lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols)
for i, line := range lines {
line = utils.Decolorise(line)
assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar),
"row %d falls back to box drawing characters: %q", i, line)
}
}
}
func TestRenderPipeSet(t *testing.T) {
cyan := style.FgCyan
red := style.FgRed
@@ -462,7 +781,7 @@ func TestRenderPipeSet(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit)
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols)
t.Log("actual cells:")
t.Log(actualStr)
expectedStr := ""
@@ -540,8 +859,8 @@ func TestGetNextPipes(t *testing.T) {
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
// rendering cells so that it's easier to see what went wrong
actualStr := renderPipeSet(pipes, pool("selected"), nil)
expectedStr := renderPipeSet(test.expected, pool("selected"), nil)
actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols)
expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols)
t.Log("expected cells:")
t.Log(expectedStr)
t.Log("actual cells:")
@@ -562,7 +881,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
}
b.ResetTimer()
for b.Loop() {
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle)
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols)
}
}
+10
View File
@@ -1221,6 +1221,16 @@
"description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.",
"default": true
},
"commitGraphStyle": {
"type": "string",
"enum": [
"auto",
"classic",
"detailed"
],
"description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.",
"default": "auto"
},
"commitAuthorShortLength": {
"type": "integer",
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
+3
View File
@@ -12,6 +12,9 @@ gui:
- black
# Not important in tests but it creates clutter in demos
showRandomTip: false
# The expected output of the tests draws the commit graph with box drawing
# characters, whichever terminal a test runs in
commitGraphStyle: classic
animateExplosion: false # takes too long
git:
# We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.