package presentation import ( "fmt" "strings" "time" "github.com/fsmiamoto/git-todo-parser/todo" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/kyokomi/emoji/v2" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) type pipeSetCacheKey struct { commitSha string commitCount int } var ( pipeSetCache = make(map[pipeSetCacheKey][][]*graph.Pipe) mutex deadlock.Mutex ) type bisectBounds struct { newIndex int oldIndex int } func GetCommitListDisplayStrings( common *common.Common, commits []*models.Commit, branches []*models.Branch, currentBranchName string, hasRebaseUpdateRefsConfig bool, fullDescription bool, cherryPickedCommitShaSet *set.Set[string], diffName string, markedBaseCommit string, timeFormat string, shortTimeFormat string, now time.Time, parseEmoji bool, selectedCommitSha string, startIdx int, endIdx int, showGraph bool, bisectInfo *git_commands.BisectInfo, showYouAreHereLabel bool, ) [][]string { mutex.Lock() defer mutex.Unlock() if len(commits) == 0 { return nil } if startIdx > len(commits) { return nil } // this is where my non-TODO commits begin rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), endIdx) filteredCommits := commits[startIdx:endIdx] bisectBounds := getbisectBounds(commits, bisectInfo) // function expects to be passed the index of the commit in terms of the `commits` slice var getGraphLine func(int) string if showGraph { // this is where the graph begins (may be beyond the TODO commits depending on startIdx, // but we'll never include TODO commits as part of the graph because it'll be messy) graphOffset := utils.Max(startIdx, rebaseOffset) pipeSets := loadPipesets(commits[rebaseOffset:]) pipeSetOffset := utils.Max(startIdx-rebaseOffset, 0) graphPipeSets := pipeSets[pipeSetOffset:utils.Max(endIdx-rebaseOffset, 0)] graphCommits := commits[graphOffset:endIdx] graphLines := graph.RenderAux( graphPipeSets, graphCommits, selectedCommitSha, ) getGraphLine = func(idx int) string { if idx >= graphOffset { return graphLines[idx-graphOffset] } else { return "" } } } else { getGraphLine = func(int) string { return "" } } // Determine the hashes of the local branches for which we want to show a // branch marker in the commits list. We only want to do this for branches // that are not the current branch, and not any of the main branches. The // goal is to visualize stacks of local branches, so anything that doesn't // contribute to a branch stack shouldn't show a marker. // // If there are other branches pointing to the current head commit, we only // want to show the marker if the rebase.updateRefs config is on. branchHeadsToVisualize := set.NewFromSlice(lo.FilterMap(branches, func(b *models.Branch, index int) (string, bool) { return b.CommitHash, // Don't consider branches that don't have a commit hash. As far // as I can see, this happens for a detached head, so filter // these out b.CommitHash != "" && // Don't show a marker for the current branch b.Name != currentBranchName && // Don't show a marker for main branches !lo.Contains(common.UserConfig.Git.MainBranches, b.Name) && // Don't show a marker for the head commit unless the // rebase.updateRefs config is on (hasRebaseUpdateRefsConfig || b.CommitHash != commits[0].Sha) })) lines := make([][]string, 0, len(filteredCommits)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" for i, commit := range filteredCommits { unfilteredIdx := i + startIdx bisectStatus = getBisectStatus(unfilteredIdx, commit.Sha, bisectInfo, bisectBounds) isYouAreHereCommit := false if showYouAreHereLabel && (commit.Action == models.ActionConflict || unfilteredIdx == rebaseOffset) { isYouAreHereCommit = true showYouAreHereLabel = false } isMarkedBaseCommit := commit.Sha != "" && commit.Sha == markedBaseCommit if isMarkedBaseCommit { willBeRebased = true } lines = append(lines, displayCommit( common, commit, branchHeadsToVisualize, hasRebaseUpdateRefsConfig, cherryPickedCommitShaSet, isMarkedBaseCommit, willBeRebased, diffName, timeFormat, shortTimeFormat, now, parseEmoji, getGraphLine(unfilteredIdx), fullDescription, bisectStatus, bisectInfo, isYouAreHereCommit, )) } return lines } func getbisectBounds(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) *bisectBounds { if !bisectInfo.Bisecting() { return nil } bisectBounds := &bisectBounds{} for i, commit := range commits { if commit.Sha == bisectInfo.GetNewSha() { bisectBounds.newIndex = i } status, ok := bisectInfo.Status(commit.Sha) if ok && status == git_commands.BisectStatusOld { bisectBounds.oldIndex = i return bisectBounds } } // shouldn't land here return nil } // precondition: slice is not empty func indexOfFirstNonTODOCommit(commits []*models.Commit) int { for i, commit := range commits { if !commit.IsTODO() { return i } } // shouldn't land here return 0 } func loadPipesets(commits []*models.Commit) [][]*graph.Pipe { // given that our cache key is a commit sha and a commit count, it's very important that we don't actually try to render pipes // when dealing with things like filtered commits. cacheKey := pipeSetCacheKey{ commitSha: commits[0].Sha, commitCount: len(commits), } pipeSets, ok := pipeSetCache[cacheKey] if !ok { // pipe sets are unique to a commit head. and a commit count. Sometimes we haven't loaded everything for that. // so let's just cache it based on that. getStyle := func(commit *models.Commit) style.TextStyle { return authors.AuthorStyle(commit.AuthorName) } pipeSets = graph.GetPipeSets(commits, getStyle) pipeSetCache[cacheKey] = pipeSets } return pipeSets } // similar to the git_commands.BisectStatus but more gui-focused type BisectStatus int const ( BisectStatusNone BisectStatus = iota BisectStatusOld BisectStatusNew BisectStatusSkipped // adding candidate here which isn't present in the commands package because // we need to actually go through the commits to get this info BisectStatusCandidate // also adding this BisectStatusCurrent ) func getBisectStatus(index int, commitSha string, bisectInfo *git_commands.BisectInfo, bisectBounds *bisectBounds) BisectStatus { if !bisectInfo.Started() { return BisectStatusNone } if bisectInfo.GetCurrentSha() == commitSha { return BisectStatusCurrent } status, ok := bisectInfo.Status(commitSha) if ok { switch status { case git_commands.BisectStatusNew: return BisectStatusNew case git_commands.BisectStatusOld: return BisectStatusOld case git_commands.BisectStatusSkipped: return BisectStatusSkipped } } else { if bisectBounds != nil && index >= bisectBounds.newIndex && index <= bisectBounds.oldIndex { return BisectStatusCandidate } else { return BisectStatusNone } } // should never land here return BisectStatusNone } func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo) string { if bisectStatus == BisectStatusNone { return "" } style := getBisectStatusColor(bisectStatus) switch bisectStatus { case BisectStatusNew: return style.Sprintf("<-- " + bisectInfo.NewTerm()) case BisectStatusOld: return style.Sprintf("<-- " + bisectInfo.OldTerm()) case BisectStatusCurrent: // TODO: i18n return style.Sprintf("<-- current") case BisectStatusSkipped: return style.Sprintf("<-- skipped") case BisectStatusCandidate: return style.Sprintf("?") case BisectStatusNone: return "" } return "" } func displayCommit( common *common.Common, commit *models.Commit, branchHeadsToVisualize *set.Set[string], hasRebaseUpdateRefsConfig bool, cherryPickedCommitShaSet *set.Set[string], isMarkedBaseCommit bool, willBeRebased bool, diffName string, timeFormat string, shortTimeFormat string, now time.Time, parseEmoji bool, graphLine string, fullDescription bool, bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, isYouAreHereCommit bool, ) []string { shaColor := getShaColor(commit, diffName, cherryPickedCommitShaSet, bisectStatus, bisectInfo) bisectString := getBisectStatusText(bisectStatus, bisectInfo) actionString := "" if commit.Action != models.ActionNone { todoString := lo.Ternary(commit.Action == models.ActionConflict, "conflict", commit.Action.String()) actionString = actionColorMap(commit.Action).Sprint(todoString) + " " } tagString := "" if fullDescription { if commit.ExtraInfo != "" { tagString = style.FgMagenta.SetBold().Sprint(commit.ExtraInfo) + " " } } else { if len(commit.Tags) > 0 { tagString = theme.DiffTerminalColor.SetBold().Sprint(strings.Join(commit.Tags, " ")) + " " } if branchHeadsToVisualize.Includes(commit.Sha) && // Don't show branch head on commits that are already merged to a main branch commit.Status != models.StatusMerged && // Don't show branch head on a "pick" todo if the rebase.updateRefs config is on !(commit.IsTODO() && hasRebaseUpdateRefsConfig) { tagString = style.FgCyan.SetBold().Sprint( lo.Ternary(icons.IsIconEnabled(), icons.BRANCH_ICON, "*") + " " + tagString) } } name := commit.Name if commit.Action == todo.UpdateRef { name = strings.TrimPrefix(name, "refs/heads/") } if parseEmoji { name = emoji.Sprint(name) } mark := "" if isYouAreHereCommit { color := lo.Ternary(commit.Action == models.ActionConflict, style.FgRed, style.FgYellow) youAreHere := color.Sprintf("<-- %s ---", common.Tr.YouAreHere) mark = fmt.Sprintf("%s ", youAreHere) } else if isMarkedBaseCommit { rebaseFromHere := style.FgYellow.Sprint(common.Tr.MarkedCommitMarker) mark = fmt.Sprintf("%s ", rebaseFromHere) } else if !willBeRebased { willBeRebased := style.FgYellow.Sprint("✓") mark = fmt.Sprintf("%s ", willBeRebased) } authorFunc := authors.ShortAuthor if fullDescription { authorFunc = authors.LongAuthor } cols := make([]string, 0, 7) if commit.Divergence != models.DivergenceNone { cols = append(cols, shaColor.Sprint(lo.Ternary(commit.Divergence == models.DivergenceLeft, "↑", "↓"))) } else if icons.IsIconEnabled() { cols = append(cols, shaColor.Sprint(icons.IconForCommit(commit))) } cols = append(cols, shaColor.Sprint(commit.ShortSha())) cols = append(cols, bisectString) if fullDescription { cols = append(cols, style.FgBlue.Sprint( utils.UnixToDateSmart(now, commit.UnixTimestamp, timeFormat, shortTimeFormat), )) } cols = append( cols, actionString, authorFunc(commit.AuthorName), graphLine+mark+tagString+theme.DefaultTextColor.Sprint(name), ) return cols } func getBisectStatusColor(status BisectStatus) style.TextStyle { switch status { case BisectStatusNone: return style.FgBlack case BisectStatusNew: return style.FgRed case BisectStatusOld: return style.FgGreen case BisectStatusSkipped: return style.FgYellow case BisectStatusCurrent: return style.FgMagenta case BisectStatusCandidate: return style.FgBlue } // shouldn't land here return style.FgWhite } func getShaColor( commit *models.Commit, diffName string, cherryPickedCommitShaSet *set.Set[string], bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, ) style.TextStyle { if bisectInfo.Started() { return getBisectStatusColor(bisectStatus) } diffed := commit.Sha != "" && commit.Sha == diffName shaColor := theme.DefaultTextColor switch commit.Status { case models.StatusUnpushed: shaColor = style.FgRed case models.StatusPushed: shaColor = style.FgYellow case models.StatusMerged: shaColor = style.FgGreen case models.StatusRebasing: shaColor = style.FgBlue case models.StatusReflog: shaColor = style.FgBlue default: } if diffed { shaColor = theme.DiffTerminalColor } else if cherryPickedCommitShaSet.Includes(commit.Sha) { shaColor = theme.CherryPickedCommitTextStyle } else if commit.Divergence == models.DivergenceRight && commit.Status != models.StatusMerged { shaColor = style.FgBlue } return shaColor } func actionColorMap(action todo.TodoCommand) style.TextStyle { switch action { case todo.Pick: return style.FgCyan case todo.Drop: return style.FgRed case todo.Edit: return style.FgGreen case todo.Fixup: return style.FgMagenta case models.ActionConflict: return style.FgRed default: return style.FgYellow } }