diff --git a/docs-master/Config.md b/docs-master/Config.md index b9b75f52e..32fbd0a4e 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -301,6 +301,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 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 81a0b22da..c5e05ed94 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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. @@ -922,6 +927,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ShowIcons: false, NerdFontsVersion: "", ShowFileIcons: true, + CommitGraphStyle: "auto", CommitAuthorShortLength: 2, CommitAuthorLongLength: 17, CommitHashLength: 8, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 6d993bd87..cf5c85966 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 87e7fb031..12968fa95 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -733,6 +733,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) { diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4a99259fd..6f3049f24 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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" @@ -73,6 +74,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { startIdx, endIdx, shouldShowGraph(c), + commitGraphSymbolSet(c), c.Model().BisectInfo, ) } @@ -349,6 +351,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 { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index b0bcee30a..86938797a 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -75,6 +75,7 @@ func NewSubCommitsContext( startIdx, endIdx, shouldShowGraph(c), + commitGraphSymbolSet(c), git_commands.NewNullBisectInfo(), ) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8d66e99b1..3f6233fa7 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -934,6 +934,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) diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 8c212b0cb..7be1d1241 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -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 { diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index b37ed73d3..079c8c4da 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -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"}) diff --git a/pkg/gui/presentation/graph/branch_drawing.go b/pkg/gui/presentation/graph/branch_drawing.go new file mode 100644 index 000000000..60271ad76 --- /dev/null +++ b/pkg/gui/presentation/graph/branch_drawing.go @@ -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 +} diff --git a/pkg/gui/presentation/graph/branch_drawing_test.go b/pkg/gui/presentation/graph/branch_drawing_test.go new file mode 100644 index 000000000..651b31d23 --- /dev/null +++ b/pkg/gui/presentation/graph/branch_drawing_test.go @@ -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)) + }) + } +} diff --git a/pkg/gui/presentation/graph/cell.go b/pkg/gui/presentation/graph/cell.go index 618a2149d..c0aeb1221 100644 --- a/pkg/gui/presentation/graph/cell.go +++ b/pkg/gui/presentation/graph/cell.go @@ -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 diff --git a/pkg/gui/presentation/graph/graph.go b/pkg/gui/presentation/graph/graph.go index 1639a62e6..a8e0a1c67 100644 --- a/pkg/gui/presentation/graph/graph.go +++ b/pkg/gui/presentation/graph/graph.go @@ -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 @@ -107,19 +107,18 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt } func getNextPipes(prevPipes []Pipe, commit *models.Commit, getStyle func(c *models.Commit) *style.TextStyle) []Pipe { - maxPos := int16(0) - for _, pipe := range prevPipes { - if pipe.toPos > maxPos { - maxPos = pipe.toPos - } - } - // a pipe that terminated in the previous line has no bearing on the current line - // so we'll filter those out + // so we'll filter those out. The same goes for the pipe from a root commit to + // the empty tree, because no commit comes after a root commit. currentPipes := lo.Filter(prevPipes, func(pipe Pipe, _ int) bool { - return pipe.kind != TERMINATES + return pipe.kind != TERMINATES && !equalHashes(pipe.toHash, &EmptyTreeCommitHash) }) + maxPos := int16(-1) + for _, pipe := range currentPipes { + maxPos = max(maxPos, pipe.toPos) + } + newPipes := make([]Pipe, 0, len(currentPipes)+len(commit.ParentPtrs())) // start by assuming that we've got a brand new commit not related to any preceding commit. // (this only happens when we're doing `git log --all`). These will be tacked onto the far end. @@ -276,6 +275,7 @@ func renderPipeSet( pipes []Pipe, selectedCommitHashPtr *string, prevCommit *models.Commit, + symbolSet SymbolSet, ) string { maxPos := int16(0) commitPos := int16(0) @@ -304,17 +304,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 +343,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 +377,30 @@ func renderPipeSet( } } + // The pipe from a root commit to the empty tree gives the commit symbol the + // style of the commit, but there is no line below a root commit + for _, pipe := range pipes { + if pipe.kind == STARTS && equalHashes(pipe.toHash, &EmptyTreeCommitHash) { + cells[pipe.toPos].down = noLine + } + } + + // 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 +412,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 { diff --git a/pkg/gui/presentation/graph/graph_test.go b/pkg/gui/presentation/graph/graph_test.go index f0cafca1c..68883e742 100644 --- a/pkg/gui/presentation/graph/graph_test.go +++ b/pkg/gui/presentation/graph/graph_test.go @@ -94,6 +94,34 @@ func TestRenderCommitGraph(t *testing.T) { 5 ○───╯ │ 6 ○ ╭───╯`, }, + { + name: "with a root commit followed by an unrelated history", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2"}}, + {Hash: "2"}, + {Hash: "A", Parents: []string{"B"}}, + {Hash: "B"}, + }, + expectedOutput: ` + 1 ○ + 2 ○ + A ○ + B ○`, + }, + { + name: "with a merge of an unrelated history", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "A"}}, + {Hash: "2", Parents: []string{"3"}}, + {Hash: "A"}, + {Hash: "3"}, + }, + expectedOutput: ` + 1 ◎─╮ + 2 ○ │ + A │ ○ + 3 ○`, + }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ @@ -224,7 +252,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 +275,345 @@ 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: "root commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: &EmptyTreeCommitHash, kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5f9", // ○ with a line up + expectedStyles: []style.TextStyle{green}, + }, + { + name: "selected root 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: &EmptyTreeCommitHash, kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5f9", // ○ with a line up + expectedStyles: []style.TextStyle{highlightStyle}, + }, + { + 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 +829,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 := "" @@ -528,7 +895,7 @@ func TestGetNextPipes(t *testing.T) { Parents: []string{}, }), expected: []Pipe{ - {fromPos: 1, toPos: 1, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, + {fromPos: 0, toPos: 0, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, }, }, } @@ -540,8 +907,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 +929,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) { } b.ResetTimer() for b.Loop() { - RenderCommitGraph(commits, hashPool.Add("selected"), getStyle) + RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols) } } diff --git a/schema-master/config.json b/schema-master/config.json index 02eb3b6be..88fb11f79 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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.", diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml index 198fcbdd1..e5a893a13 100644 --- a/test/default_test_config/config.yml +++ b/test/default_test_config/config.yml @@ -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.