/
githubmirror
/
lazygit
Обзор
Документация
Войти
/
githubmirror
/
lazygit
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
pkg/commands/git_commands/branch_loader.go
518 строк
14 KB
Antoine Gaudreau Simard
perf(branches): use for-each-ref ahead-behind for git >= 2.41
27 апр 2026, 08:09
27 апр 2026, 08:09
3d14881
Код
Авторство
О чём код?
package git_commands import ( "fmt" "regexp" "slices" "strconv" "strings" "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "golang.org/x/sync/errgroup" ) // context: // we want to only show 'safe' branches (ones that haven't e.g. been deleted) // which `git branch -a` gives us, but we also want the recency data that // git reflog gives us. // So we get the HEAD, then append get the reflog branches that intersect with // our safe branches, then add the remaining safe branches, ensuring uniqueness // along the way // if we find out we need to use one of these functions in the git.go file, we // can just pull them out of here and put them there and then call them from in here type BranchLoaderConfigCommands interface { Branches(cmd oscommands.ICmdObjBuilder) map[string]*BranchConfig } type BranchInfo struct { RefName string DisplayName string // e.g. '(HEAD detached at 123asdf)' DetachedHead bool } // BranchLoader returns a list of Branch objects for the current repo type BranchLoader struct { *common.Common *GitCommon cmd oscommands.ICmdObjBuilder getCurrentBranchInfo func() (BranchInfo, error) config BranchLoaderConfigCommands } func NewBranchLoader( cmn *common.Common, gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, getCurrentBranchInfo func() (BranchInfo, error), config BranchLoaderConfigCommands, ) *BranchLoader { return &BranchLoader{ Common: cmn, GitCommon: gitCommon, cmd: cmd, getCurrentBranchInfo: getCurrentBranchInfo, config: config, } } // Load the list of branches for the current repo func (self *BranchLoader) Load(reflogCommits []*models.Commit, mainBranches *MainBranches, oldBranches []*models.Branch, loadBehindCounts bool, onWorker func(func() error), renderFunc func(), ) ([]*models.Branch, error) { branches := self.obtainBranches() if self.UserConfig().Git.LocalBranchSortOrder == "recency" { reflogBranches := self.obtainReflogBranches(reflogCommits) // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches branchesWithRecency := make([]*models.Branch, 0) outer: for _, reflogBranch := range reflogBranches { for j, branch := range branches { if branch.Head { continue } if strings.EqualFold(reflogBranch.Name, branch.Name) { branch.Recency = reflogBranch.Recency branchesWithRecency = append(branchesWithRecency, branch) branches = utils.Remove(branches, j) continue outer } } } // Sort branches that don't have a recency value alphabetically // (we're really doing this for the sake of deterministic behaviour across git versions) slices.SortFunc(branches, func(a *models.Branch, b *models.Branch) int { return strings.Compare(a.Name, b.Name) }) branches = utils.Prepend(branches, branchesWithRecency...) } foundHead := false for i, branch := range branches { if branch.Head { foundHead = true branch.Recency = " *" branches = utils.Move(branches, i, 0) break } } if !foundHead { info, err := self.getCurrentBranchInfo() if err != nil { return nil, err } branches = utils.Prepend(branches, &models.Branch{Name: info.RefName, DisplayName: info.DisplayName, Head: true, DetachedHead: info.DetachedHead, Recency: " *"}) } configBranches := self.config.Branches(self.cmd) for _, branch := range branches { match := configBranches[branch.Name] if match != nil { branch.UpstreamRemote = match.Remote branch.UpstreamBranch = match.Merge } // If the branch already existed, take over its BehindBaseBranch value // to reduce flicker if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool { return b.Name == branch.Name }); found { branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load()) } } if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { onWorker(func() error { return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc) }) } return branches, nil } func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches( branches []*models.Branch, mainBranches *MainBranches, renderFunc func(), ) error { mainBranchRefs := mainBranches.Get() if len(mainBranchRefs) == 0 { return nil } if self.version.IsAtLeast(2, 41, 0) { return self.getBehindBaseBranchValuesFast(branches, mainBranchRefs, renderFunc) } return self.getBehindBaseBranchValuesLegacy(branches, mainBranches, renderFunc) } func (self *BranchLoader) getBehindBaseBranchValuesLegacy( branches []*models.Branch, mainBranches *MainBranches, renderFunc func(), ) error { t := time.Now() errg := errgroup.Group{} for _, branch := range branches { errg.Go(func() error { baseBranch, err := self.GetBaseBranch(branch, mainBranches) if err != nil { return err } behind := 0 // prime it in case something below fails if baseBranch != "" { output, err := self.cmd.New( NewGitCmd("rev-list"). Arg("--left-right"). Arg("--count"). Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), baseBranch)). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return err } // The format of the output is "<ahead>\t<behind>" aheadBehindStr := strings.Split(strings.TrimSpace(output), "\t") if len(aheadBehindStr) == 2 { if value, err := strconv.Atoi(aheadBehindStr[1]); err == nil { behind = value } } } branch.BehindBaseBranch.Store(int32(behind)) return nil }) } err := errg.Wait() self.Log.Debugf("time to get behind base branch values for all branches (legacy): %s", time.Since(t)) renderFunc() return err } // Holds parsed values from a single %(ahead-behind:<base>) field. type aheadBehind struct { ahead, behind int } type branchAheadBehind struct { refName string aheadBehinds []aheadBehind } // Parses output produced by: // // git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads // // Lines whose NUL-split column count doesn't match (1 + numBases) are dropped. // Blank lines are ignored. // Individual malformed ahead-behind fields produce {valid: false} entries func parseAheadBehindForEachRefOutput( output string, numBases int, // number of %(ahead-behind:...) tokens ) []branchAheadBehind { if output == "" { return nil } lines := strings.Split(output, "\n") result := make([]branchAheadBehind, 0, len(lines)) for _, line := range lines { cols := strings.Split(line, "\x00") if len(cols) != numBases+1 { continue } refName := cols[0] aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) { return parseAheadBehindField(col) }) entry := branchAheadBehind{ refName: refName, aheadBehinds: aheadBehinds, } result = append(result, entry) } return result } func parseAheadBehindField(s string) (aheadBehind, bool) { parts := strings.Fields(s) if len(parts) != 2 { return aheadBehind{}, false } ahead, err1 := strconv.Atoi(parts[0]) behind, err2 := strconv.Atoi(parts[1]) if err1 != nil || err2 != nil { return aheadBehind{}, false } return aheadBehind{ahead: ahead, behind: behind}, true } // Picks the "closest" base by smallest ahead value (commits the branch // has that the base doesn't = roughly "since fork point") and returns // its behind value. // Ties are broken by index order func selectBehindForBranch(aheadBehinds []aheadBehind) int { return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool { return a.ahead < b.ahead }).behind } // The output format is: // // <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n // // with one ahead-behind field per base, in the same order as mainBranchRefs. // // Requires git >= 2.41 (when %(ahead-behind:...) was added). func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string { formatParts := make([]string, 0, 1+len(mainBranchRefs)) formatParts = append(formatParts, "%(refname)") for _, ref := range mainBranchRefs { formatParts = append(formatParts, "%(ahead-behind:"+ref+")") } format := strings.Join(formatParts, "%00") return NewGitCmd("for-each-ref"). Arg("--format=" + format). Arg("refs/heads"). ToArgv() } func (self *BranchLoader) getBehindBaseBranchValuesFast( branches []*models.Branch, mainBranchRefs []string, renderFunc func(), ) error { t := time.Now() output, err := self.cmd.New( buildAheadBehindForEachRefArgs(mainBranchRefs), ).DontLog().RunWithOutput() if err != nil { return err } parsed := parseAheadBehindForEachRefOutput(output, len(mainBranchRefs)) branchByRef := lo.KeyBy(branches, (*models.Branch).FullRefName) for _, p := range parsed { if branch, ok := branchByRef[p.refName]; ok { behind := selectBehindForBranch(p.aheadBehinds) branch.BehindBaseBranch.Store(int32(behind)) delete(branchByRef, p.refName) } } // Branches not in parse are default to 0 for _, branch := range branchByRef { branch.BehindBaseBranch.Store(0) } self.Log.Debugf("time to get behind base branch values for all branches (fast): %s", time.Since(t)) renderFunc() return nil } // Find the base branch for the given branch (i.e. the main branch that the // given branch was forked off of) // // Note that this function may return an empty string even if the returned error // is nil, e.g. when none of the configured main branches exist. This is not // considered an error condition, so callers need to check both the returned // error and whether the returned base branch is empty (and possibly react // differently in both cases). func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *MainBranches) (string, error) { mergeBase := mainBranches.GetMergeBase(branch.FullRefName()) if mergeBase == "" { return "", nil } output, err := self.cmd.New( NewGitCmd("for-each-ref"). Arg("--contains"). Arg(mergeBase). Arg("--format=%(refname)"). Arg(mainBranches.Get()...). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return "", err } trimmedOutput := strings.TrimSpace(output) split := strings.Split(trimmedOutput, "\n") if len(split) == 0 || split[0] == "" { return "", nil } return split[0], nil } func (self *BranchLoader) obtainBranches() []*models.Branch { output, err := self.getRawBranches() if err != nil { panic(err) } trimmedOutput := strings.TrimSpace(output) outputLines := strings.Split(trimmedOutput, "\n") return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) { if line == "" { return nil, false } split := strings.Split(line, "\x00") if len(split) != len(branchFields) { // Ignore line if it isn't separated into the expected number of parts // This is probably a warning message, for more info see: // https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439 return nil, false } storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency" return obtainBranch(split, storeCommitDateAsRecency), true }) } func (self *BranchLoader) getRawBranches() (string, error) { format := strings.Join( lo.Map(branchFields, func(thing string, _ int) string { return "%(" + thing + ")" }), "%00", ) var sortOrder string switch strings.ToLower(self.UserConfig().Git.LocalBranchSortOrder) { case "recency", "date": sortOrder = "-committerdate" case "alphabetical": sortOrder = "refname" default: sortOrder = "refname" } cmdArgs := NewGitCmd("for-each-ref"). Arg(fmt.Sprintf("--sort=%s", sortOrder)). Arg(fmt.Sprintf("--format=%s", format)). Arg("refs/heads"). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } var branchFields = []string{ "HEAD", "refname:short", "upstream:short", "upstream:track", "push:track", "subject", "objectname", "committerdate:unix", } // Obtain branch information from parsed line output of getRawBranches() func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch { headMarker := split[0] fullName := split[1] upstreamName := split[2] track := split[3] pushTrack := split[4] subject := split[5] commitHash := split[6] commitDate := split[7] name := strings.TrimPrefix(fullName, "heads/") aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track) aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack) recency := "" if storeCommitDateAsRecency { if unixTimestamp, err := strconv.ParseInt(commitDate, 10, 64); err == nil { recency = utils.UnixToTimeAgo(unixTimestamp) } } return &models.Branch{ Name: name, Recency: recency, AheadForPull: aheadForPull, BehindForPull: behindForPull, AheadForPush: aheadForPush, BehindForPush: behindForPush, UpstreamGone: gone, Head: headMarker == "*", Subject: subject, CommitHash: commitHash, } } func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) { if upstreamName == "" { // if we're here then it means we do not have a local version of the remote. // The branch might still be tracking a remote though, we just don't know // how many commits ahead/behind it is return "?", "?", false } if track == "[gone]" { return "?", "?", true } ahead := parseDifference(track, `ahead (\d+)`) behind := parseDifference(track, `behind (\d+)`) return ahead, behind, false } func parseDifference(track string, regexStr string) string { re := regexp.MustCompile(regexStr) match := re.FindStringSubmatch(track) if len(match) > 1 { return match[1] } return "0" } // TODO: only look at the new reflog commits, and otherwise store the recencies in // int form against the branch to recalculate the time ago func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { foundBranches := set.New[string]() re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`) reflogBranches := make([]*models.Branch, 0, len(reflogCommits)) for _, commit := range reflogCommits { match := re.FindStringSubmatch(commit.Name) if len(match) != 3 { continue } recency := utils.UnixToTimeAgo(commit.UnixTimestamp) for _, branchName := range match[1:] { if !foundBranches.Includes(branchName) { foundBranches.Add(branchName) reflogBranches = append(reflogBranches, &models.Branch{ Recency: recency, Name: branchName, }) } } } return reflogBranches }