package helpers import ( "strings" "sync" "sync/atomic" "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) type RefreshHelper struct { c *HelperCommon refsHelper *RefsHelper mergeAndRebaseHelper *MergeAndRebaseHelper patchBuildingHelper *PatchBuildingHelper stagingHelper *StagingHelper mergeConflictsHelper *MergeConflictsHelper worktreeHelper *WorktreeHelper searchHelper *SearchHelper // Tracks repos for which the user has dismissed the "select base GitHub remote" // prompt, to avoid re-prompting on every subsequent refresh within the same session. // Keyed by repo path so that switching to a different repo while lazygit is running // still triggers the prompt there. githubBaseRemotePromptDismissed map[string]bool // Last observed refs+HEAD fingerprint, used by the background poller to // decide whether a real refresh is needed. Written at the end of every // refresh that re-read refs/commits, read by the poller. refsSnapshotMutex deadlock.Mutex refsSnapshot string // branchLoadSeq hands out a monotonically increasing sequence number to // each branch load (via Add, on the worker); appliedBranchLoadSeq is the // highest sequence whose result has been written to the model (touched only // on the UI thread, inside the bounce). Together they let a branch load's // bounce drop its write if a later-started load has already applied, so // concurrent branch loads don't clobber each other out of order. branchLoadSeq atomic.Int64 appliedBranchLoadSeq int64 } func NewRefreshHelper( c *HelperCommon, refsHelper *RefsHelper, mergeAndRebaseHelper *MergeAndRebaseHelper, patchBuildingHelper *PatchBuildingHelper, stagingHelper *StagingHelper, mergeConflictsHelper *MergeConflictsHelper, worktreeHelper *WorktreeHelper, searchHelper *SearchHelper, ) *RefreshHelper { return &RefreshHelper{ c: c, refsHelper: refsHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, patchBuildingHelper: patchBuildingHelper, stagingHelper: stagingHelper, mergeConflictsHelper: mergeConflictsHelper, worktreeHelper: worktreeHelper, searchHelper: searchHelper, } } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { self.performRefresh(options, false) } // RefreshFromWorker is Refresh for callers already running on a worker // goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI // thread. See IGuiCommon.RefreshFromWorker. func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { self.performRefresh(options, true) } type refreshEnv struct { // whether this is a background refresh (which selects the dispatch variant that // doesn't count towards lazygit being busy) background bool // the repo generation captured when the refresh started generation int } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") } t := time.Now() defer func() { self.c.Log.Infof("Refresh took %s", time.Since(t)) }() if options.Scope == nil { self.c.Log.Infof( "refreshing all scopes in %s mode", getModeName(options.Mode), ) } else { self.c.Log.Infof( "refreshing the following scopes in %s mode: %s", getModeName(options.Mode), strings.Join(getScopeNames(options.Scope), ","), ) } // f runs on the UI thread when the refresh was initiated there, and also for // BLOCK_UI, which dispatches f onto the UI thread regardless of the caller. // Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker. // This, not calledFromWorker alone, is what decides whether a scope capture // runs inline or has to hop (see captureOnUIThread). fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker // Debug-only guard: every refresh must be issued from the entry point that // matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a // worker. We check the caller's own goroutine here, before a BLOCK_UI // refresh dispatches f onto the UI thread, so it holds regardless of the // mode. goid stays out of production control flow (debug only). if self.c.GetConfig().GetDebug() && self.c.GocuiGui().IsUIThread() == calledFromWorker { panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } f := func() { // Capture the repo generation once, here at the start, so every scope's // bounce is guarded against the same baseline. env := refreshEnv{ background: options.Background, generation: self.c.State().GetRepoGeneration(), } var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { // not refreshing staging/patch-building unless explicitly requested because we only need // to refresh those while focused. scopeSet = set.NewFromSlice([]types.RefreshableView{ types.COMMITS, types.BRANCHES, types.FILES, types.STASH, types.REFLOG, types.TAGS, types.REMOTES, types.WORKTREES, types.STATUS, types.BISECT_INFO, types.STAGING, types.PULL_REQUESTS, }) } else { scopeSet = set.NewFromSlice(options.Scope) } // Expand co-refreshing scopes up front so downstream conditions can be // simple single-scope checks. The relationships are: // - whenever the reflog or bisect info changes, commits and branches // can change too (e.g. switching branches updates the reflog and // can move HEAD), so refresh commits + branches alongside // - submodules are refreshed as part of the files refresh // - merge conflicts are part of what the files refresh produces // - pull requests are fetched for the tracking branches against the // remotes, so refresh both alongside to fetch against fresh data if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } if scopeSet.Includes(types.SUBMODULES) { scopeSet.Add(types.FILES) } if scopeSet.Includes(types.FILES) { scopeSet.Add(types.MERGE_CONFLICTS) } if scopeSet.Includes(types.PULL_REQUESTS) { scopeSet.Add(types.BRANCHES, types.REMOTES) } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race // of git's state changing externally while (or right after) we are // refreshing; the risk is one potential extra refresh, but capturing the // snapshot at the end would risk missing one, which is worse. self.updateRefsSnapshotIfRelevant(scopeSet) wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { self.onWorker(env.background, func(t gocui.Task) error { f() return nil }) } else { wg.Add(1) go utils.Safe(func() { t := time.Now() defer wg.Done() f() self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) }) } } branchesAndRemotesWg := sync.WaitGroup{} // The pull-request fetch (below) needs the just-loaded branches and // remotes. Their model writes are bounced onto the UI thread, so the // fetch worker can't read them back from the model without racing (and // would see the pre-refresh values); instead the branches and remotes // loads stash what they loaded here, and the wait on // branchesAndRemotesWg gives the fetch the happens-before to read them. var loadedBranches []*models.Branch var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. // Capture the commits, reflog and branches refresh inputs (model, // contexts, modes) on the UI thread, before the git work is dispatched // to a worker, so the workers compute from an immutable snapshot // instead of reading state the UI thread concurrently mutates. var capturedCommits capturedCommitState var capturedReflog capturedReflogState var capturedBranches capturedBranchState self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) branchesAndRemotesWg.Done() }) } else { branchesAndRemotesWg.Add(1) refresh("branches", func() { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and uses the reflog we captured up front, as it always has. loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) branchesAndRemotesWg.Done() }) refresh("reflog", func() { _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one // if we've asked specifically for rebase commits and not those other things var rebaseHashPool *utils.StringPool var rebaseCommits []*models.Commit self.captureOnUIThread(fRunsOnUIThread, env.background, func() { rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() }) refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedSubCommits = self.captureSubCommitState() }) refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { var capturedCommitFiles capturedCommitFilesState self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() }) refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) } fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedFiles = self.captureFilesState() }) fileWg.Add(1) refresh("files", func() { _ = self.refreshFilesAndSubmodules(capturedFiles, env) fileWg.Done() }) } if scopeSet.Includes(types.STASH) { var stashFilterPath string self.captureOnUIThread(fRunsOnUIThread, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() }) refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) } if scopeSet.Includes(types.TAGS) { refresh("tags", func() { _ = self.refreshTags(env) }) } if scopeSet.Includes(types.REMOTES) { // Capture the previously-selected remote on the UI thread; the worker // needs it to keep the remote-branches selection valid, and reading // the Remotes context off the UI thread races its render. var prevSelectedRemote *models.Remote self.captureOnUIThread(fRunsOnUIThread, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() }) branchesAndRemotesWg.Add(1) refresh("remotes", func() { loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) branchesAndRemotesWg.Done() }) } if scopeSet.Includes(types.PULL_REQUESTS) { refresh("pull requests", func() { branchesAndRemotesWg.Wait() // Use the branches and remotes the loads above stashed, not // Model().Branches/Remotes: those writes are bounced onto the // UI thread and may not have landed on this worker yet. The // wait above orders us after both loads have stashed theirs. self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { refresh("worktrees", func() { self.refreshWorktrees(env) }) } if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() // Bounce onto the UI thread so this runs after the files // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. Guard on the generation so a // repo switch mid-refresh drops it, like the model bounces. self.onUIThreadUnlessRepoChanged(env, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) }) } if scopeSet.Includes(types.PATCH_BUILDING) { refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) } if scopeSet.Includes(types.MERGE_CONFLICTS) { refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) } self.refreshStatus(env) wg.Wait() if options.Then != nil { // Queue Then via OnUIThread so it runs *after* the refresh-scope // functions' model-update bounces (which are already queued by // now), not synchronously here — at this point the workers have // returned but their bounces haven't been processed yet, so // invoking Then synchronously would run it on a model that's // still pre-refresh. self.onUIThread(env.background, options.Then) } } if options.Mode == types.BLOCK_UI { self.c.OnUIThread(func() error { f() return nil }) return } f() } // SetRefsSnapshot stores the given snapshot as the last observed refs state. // Called externally by the background poller at startup to seed the snapshot, // and internally by Refresh at the end of a refs-touching refresh. func (self *RefreshHelper) SetRefsSnapshot(snapshot string) { self.refsSnapshotMutex.Lock() defer self.refsSnapshotMutex.Unlock() self.refsSnapshot = snapshot } // RefsSnapshotChangedSince reports whether the given snapshot differs from // the last observed one. Pure read; does not update internal state. func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { self.refsSnapshotMutex.Lock() defer self.refsSnapshotMutex.Unlock() // An empty stored snapshot means no refresh has captured one yet, so we // have no baseline to compare against and report "unchanged" rather than // firing a spurious refresh. This can only be the unset zero value: a // snapshot we actually computed is never empty, because its HEAD component // is always non-empty (a branch ref when attached, a hash when detached — // even a repo with no commits yields "ref: refs/heads/main"). if self.refsSnapshot == "" { return false } return snapshot != self.refsSnapshot } // updateRefsSnapshotIfRelevant captures a fresh refs snapshot from disk at the // start of a refresh that re-reads refs/commits (see the call site for why we // capture before reading the model rather than after). This keeps the // background poller's stored snapshot in sync with what's been observed by the // UI, so in-app commands and focus-in refreshes don't cause the next poll to // spuriously re-trigger. // // We check just COMMITS and BRANCHES because the scope-expansion step at the // top of Refresh has already added these whenever REFLOG or BISECT_INFO are // in scope, and whenever a nil scope was passed. func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) { if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) { return } snapshot, err := self.c.Git().Status.RefsSnapshot() if err != nil { self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err) return } self.SetRefsSnapshot(snapshot) } func getScopeNames(scopes []types.RefreshableView) []string { scopeNameMap := map[types.RefreshableView]string{ types.COMMITS: "commits", types.REBASE_COMMITS: "rebaseCommits", types.BRANCHES: "branches", types.FILES: "files", types.SUBMODULES: "submodules", types.SUB_COMMITS: "subCommits", types.STASH: "stash", types.REFLOG: "reflog", types.TAGS: "tags", types.REMOTES: "remotes", types.WORKTREES: "worktrees", types.STATUS: "status", types.BISECT_INFO: "bisect", types.STAGING: "staging", types.PATCH_BUILDING: "patchBuilding", types.MERGE_CONFLICTS: "mergeConflicts", types.COMMIT_FILES: "commitFiles", types.PULL_REQUESTS: "pullRequests", } return lo.Map(scopes, func(scope types.RefreshableView, _ int) string { return scopeNameMap[scope] }) } func getModeName(mode types.RefreshMode) string { switch mode { case types.SYNC: return "sync" case types.ASYNC: return "async" case types.BLOCK_UI: return "block-ui" default: return "unknown mode" } } // During startup, the bottleneck is fetching the reflog entries, which we need // in order to sort the branches by recency. So we have two phases: INITIAL and // COMPLETE. In the INITIAL phase we don't have any reflog commits yet, so we // show the branches right away sorted by whatever we have (typically nothing, // i.e. not by recency), then load the reflog on a worker and refresh the // branches again, this time recency-sorted. From then on we're in the COMPLETE // phase and load the reflog synchronously before refreshing the branches. // // The immediate refresh must run before we spawn the async one, not after: that // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. // capturedReflogState holds the reflog refresh's model/mode inputs, gathered on // the UI thread before the git work runs. The existing reflog slices feed the // incremental fetch (we only load entries newer than the ones we already have). type capturedReflogState struct { reflogCommits []*models.Commit filteredReflogCommits []*models.Commit hashPool *utils.StringPool filteringActive bool filterPath string filterAuthor string } // captureReflogState reads the reflog refresh's inputs into an immutable // snapshot. It must run on the UI thread. func (self *RefreshHelper) captureReflogState() capturedReflogState { return capturedReflogState{ reflogCommits: self.c.Model().ReflogCommits, filteredReflogCommits: self.c.Model().FilteredReflogCommits, hashPool: self.c.Model().HashPool, filteringActive: self.c.Modes().Filtering.Active(), filterPath: self.c.Modes().Filtering.GetPath(), filterAuthor: self.c.Modes().Filtering.GetAuthor(), } } // capturedBranchState holds the branches refresh's model inputs, gathered on the // UI thread before the git work runs. oldBranches is used only to carry over the // previous BehindBaseBranch values (to reduce flicker) — an atomic each, so a // pre-refresh snapshot serves both the immediate and recency loads identically. type capturedBranchState struct { mainBranches *git_commands.MainBranches oldBranches []*models.Branch } // captureBranchState reads the branches refresh's model inputs into an immutable // snapshot. It must run on the UI thread. func (self *RefreshHelper) captureBranchState() capturedBranchState { return capturedBranchState{ mainBranches: self.c.Model().MainBranches, oldBranches: self.c.Model().Branches, } } func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) self.onWorker(env.background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) return branches case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) } return nil } // capturedCommitState holds everything the commits refresh reads from the // model, contexts, and modes. It is gathered on the UI thread (see // captureCommitsState) before the git work is dispatched to a worker, so the // worker computes from an immutable snapshot rather than reading state the UI // thread concurrently mutates. type capturedCommitState struct { selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool filterPath string filterAuthor string mainBranches *git_commands.MainBranches hashPool *utils.StringPool parentIsLocalCommits bool } // captureCommitsState reads the commits refresh's model/context/mode inputs // into an immutable snapshot. It must run on the UI thread. func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { var selectionRange *localCommitSelectionRange if commitSelection == types.KeepCommitSelectionByHash { selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) } parentCtx := self.c.Contexts().CommitFiles.GetParentContext() return capturedCommitState{ selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), filterPath: self.c.Modes().Filtering.GetPath(), filterAuthor: self.c.Modes().Filtering.GetAuthor(), mainBranches: self.c.Model().MainBranches, hashPool: self.c.Model().HashPool, parentIsLocalCommits: parentCtx != nil && parentCtx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY, } } func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) { _ = self.refreshCommitsWithLimit(captured, commitSelection, env) if captured.parentIsLocalCommits { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up // showing the contents of a different commit than the one we initially entered. // Ideally we would know when to refresh the commit files context and when not to, // or perhaps we could just pop that context off the stack whenever cycling windows. // For now the awkwardness remains. // // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). self.onUIThreadUnlessRepoChanged(env, func() error { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) // Capture the diff endpoints here, on the UI thread and after // ReInit has set them, before dispatching the git work. capturedCommitFiles := self.captureCommitFilesState() self.onWorker(env.background, func(gocui.Task) error { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) return nil }) } return nil }) } } func (self *RefreshHelper) determineCheckedOutRef() models.Ref { if rebasedBranch := self.c.Git().Status.BranchBeingRebased(); rebasedBranch != "" { // During a rebase we're on a detached head, so cannot determine the // branch name in the usual way. We need to read it from the // ".git/rebase-merge/head-name" file instead. return &models.Branch{Name: strings.TrimPrefix(rebasedBranch, "refs/heads/")} } if bisectInfo := self.c.Git().Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { // Likewise, when we're bisecting we're on a detached head as well. In // this case we read the branch name from the ".git/BISECT_START" file. return &models.Branch{Name: bisectInfo.GetStartHash()} } // In all other cases, get the branch name by asking git what branch is // checked out. Note that if we're on a detached head (for reasons other // than rebasing or bisecting, i.e. it was explicitly checked out), then // this will return an empty string. if branchName, err := self.c.Git().Branch.CurrentBranchName(); err == nil && branchName != "" { return &models.Branch{Name: branchName} } // Should never get here unless the working copy is corrupt return nil } func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) error { checkedOutRef := self.determineCheckedOutRef() refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: true, RefName: refName, RefForPushedStatus: checkedOutRef, All: captured.showWholeGitGraph, MainBranches: captured.mainBranches, HashPool: captured.hashPool, }, ) if err != nil { return err } workingTreeState := self.c.Git().Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState if checkedOutRef != nil { self.c.Model().CheckedOutBranch = checkedOutRef.RefName() } else { self.c.Model().CheckedOutBranch = "" } scrollSelectionIntoView := false switch commitSelection { case types.SelectHeadCommit: if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: if captured.selectionRange != nil { selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) if found { self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) scrollSelectionIntoView = didMove } } case types.KeepCommitSelectionIndex: // The caller set the selection index deliberately; leave it untouched. } if scrollSelectionIntoView { // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().LocalCommits.FocusLine(true) return nil }) } return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) return nil } type localCommitSelectionRange struct { selectedHash string selectedIsTODO bool rangeStartHash string rangeStartIsTODO bool selectedIdx int rangeStartIdx int mode traits.RangeSelectMode } func captureLocalCommitSelectionRange( commits []*models.Commit, selectedIdx int, rangeStartIdx int, mode traits.RangeSelectMode, ) *localCommitSelectionRange { if !hasRestorableCommitHash(commits, selectedIdx) || !hasRestorableCommitHash(commits, rangeStartIdx) { return nil } return &localCommitSelectionRange{ selectedHash: commits[selectedIdx].Hash(), selectedIsTODO: commits[selectedIdx].IsTODO(), rangeStartHash: commits[rangeStartIdx].Hash(), rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), selectedIdx: selectedIdx, rangeStartIdx: rangeStartIdx, mode: mode, } } func findLocalCommitSelectionRange( commits []*models.Commit, selectionRange *localCommitSelectionRange, ) (int, int, bool, bool) { selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) if !foundSelected || !foundRangeStart { return 0, 0, false, false } didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx return selectedIdx, rangeStartIdx, didMove, true } // findCommitByHashPreferringTODOStatus finds the commit with the given hash. // When both a TODO and a non-TODO commit share that hash - which happens while // reverting or cherry-picking, where the rebase TODO entry has the same hash as // the real commit - it returns the one whose TODO status matches isTODO. When // only one commit has the hash, it is returned regardless of its TODO status, // so that a selected commit which turned into a TODO entry across the refresh is // still found (e.g. when starting an interactive rebase that stops to edit it). func findCommitByHashPreferringTODOStatus(commits []*models.Commit, hash string, isTODO bool) (int, bool) { fallbackIdx := -1 for idx, commit := range commits { if commit.Hash() != hash { continue } if commit.IsTODO() == isTODO { return idx, true } if fallbackIdx == -1 { fallbackIdx = idx } } return fallbackIdx, fallbackIdx != -1 } func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } // capturedSubCommitState holds the sub-commits refresh's model/context/mode // inputs, gathered on the UI thread (see captureSubCommitState) before the git // work is dispatched to a worker. type capturedSubCommitState struct { ref models.Ref limitCommits bool refToShowDivergenceFrom string filterPath string filterAuthor string mainBranches *git_commands.MainBranches hashPool *utils.StringPool } // captureSubCommitState reads the sub-commits refresh's inputs into an immutable // snapshot. It must run on the UI thread. func (self *RefreshHelper) captureSubCommitState() capturedSubCommitState { return capturedSubCommitState{ ref: self.c.Contexts().SubCommits.GetRef(), limitCommits: self.c.Contexts().SubCommits.GetLimitCommits(), refToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), filterPath: self.c.Modes().Filtering.GetPath(), filterAuthor: self.c.Modes().Filtering.GetAuthor(), mainBranches: self.c.Model().MainBranches, hashPool: self.c.Model().HashPool, } } func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, env refreshEnv) error { if captured.ref == nil { return nil } commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: false, RefName: captured.ref.FullRefName(), RefToShowDivergenceFrom: captured.refToShowDivergenceFrom, RefForPushedStatus: captured.ref, MainBranches: captured.mainBranches, HashPool: captured.hashPool, }, ) if err != nil { return err } self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) return nil }) self.refreshView(self.c.Contexts().SubCommits, env) return nil } func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { authors := self.c.Model().Authors for _, commit := range commits { if _, ok := authors[commit.AuthorEmail]; !ok { authors[commit.AuthorEmail] = &models.Author{ Email: commit.AuthorEmail, Name: commit.AuthorName, } } } } // capturedCommitFilesState holds the commit-files refresh's context/mode inputs // (the diff endpoints), gathered on the UI thread before the git work runs. type capturedCommitFilesState struct { from string to string reverse bool } // captureCommitFilesState reads the commit-files refresh's diff endpoints into // an immutable snapshot. It must run on the UI thread. func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) return capturedCommitFilesState{from: from, to: to, reverse: reverse} } func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() return nil }) self.refreshView(self.c.Contexts().CommitFiles, env) return nil } // captureRebaseCommitState reads the rebase-commits refresh's model inputs into // an immutable snapshot. It must run on the UI thread. func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPool, commits []*models.Commit) { return self.c.Model().HashPool, self.c.Model().Commits } func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, env refreshEnv) error { updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } workingTreeState := self.c.Git().Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) return nil } func (self *RefreshHelper) refreshTags(env refreshEnv) error { tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Tags = tags return nil }) self.refreshView(self.c.Contexts().Tags, env) return nil } func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleConfig, error) { return self.c.Git().Submodule.GetConfigs(nil) } // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, loadBehindCounts, func(f func() error) { self.onWorker(env.background, func(_ gocui.Task) error { return f() }) }, func() { self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().Branches.HandleRender() self.refreshStatus(env) return nil }) }) if err != nil { self.c.Log.Error(err) } var worktrees []*models.Worktree if refreshWorktrees { worktrees = self.loadWorktrees() } self.onUIThreadUnlessRepoChanged(env, func() error { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this // makes the later-started (recency-sorted) one win regardless of which // finishes first, so its result isn't clobbered by the stale immediate one. if loadSeq < self.appliedBranchLoadSeq { return nil } self.appliedBranchLoadSeq = loadSeq // Read the currently-selected branch before overwriting the list, so we // can restore it by name below. Reading it here in the bounce keeps it on // the UI thread. prevSelectedBranch := self.c.Contexts().Branches.GetSelected() self.c.Model().Branches = branches // Rebuilding here (rather than on the worker) means the map is built from // the branches we just wrote, on the UI thread. self.rebuildPullRequestsMap() if refreshWorktrees { self.c.Model().Worktrees = worktrees self.refreshView(self.c.Contexts().Worktrees, env) } // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and keeps the list and selection updating in // the same frame. switch branchSelection { case types.KeepBranchSelectionByName: if prevSelectedBranch != nil { self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) if found { self.c.Contexts().Branches.SetSelectedLineIdx(idx) } } case types.SelectCheckedOutBranch: // The checked-out branch is always at the top of the list. Setting // the selection doesn't scroll the view, so also reset the origin. self.c.Contexts().Branches.SetSelectedLineIdx(0) self.c.Contexts().Branches.GetView().SetOriginY(0) } // Need to re-render the commits view because the visualization of local // branch heads might have changed self.c.Contexts().LocalCommits.HandleRender() return nil }) self.refreshView(self.c.Contexts().Branches, env) self.refreshStatus(env) // Return the freshly-loaded branches so the caller can hand them to the PR // fetch without reading them back from the (bounce-written) model. return branches } func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, env refreshEnv) error { configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err } if err := self.refreshStateFiles(captured, env, configs); err != nil { return err } self.refreshView(self.c.Contexts().Submodules, env) self.refreshView(self.c.Contexts().Files, env) return nil } // onUIThreadUnlessRepoChanged bounces a refresh's model/view update onto the UI // thread, but drops it if the repo was switched while the refresh was in flight. // Refresh workers do their git work off the UI thread and enqueue their model // writes here; a repo switch (which replaces the whole model and context tree) // bumps the generation, so a write captured under the old generation must not // clobber the new repo's state. The generation is captured once at the start of // the refresh and carried in env (see refreshEnv). func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) { self.onUIThread(env.background, func() error { if self.c.State().GetRepoGeneration() != env.generation { return nil } return f() }) } // onWorker and onUIThread pick the foreground or background variant of the // corresponding dispatch method depending on whether we're servicing a // background refresh. Background refreshes (auto-fetch and friends) must not // count towards lazygit being busy, or they'd spuriously block a repo switch; // see the *Background methods on gocui.Gui. func (self *RefreshHelper) onWorker(background bool, f func(gocui.Task) error) { if background { self.c.OnWorkerBackground(f) } else { self.c.OnWorker(f) } } func (self *RefreshHelper) onUIThread(background bool, f func() error) { if background { self.c.OnUIThreadBackground(f) } else { self.c.OnUIThread(f) } } // captureOnUIThread runs fn on the UI thread and returns once it has run. fn // reads the model/context/mode state a refresh scope needs into locals, so the // worker that follows computes from an immutable snapshot instead of reading // state the UI thread concurrently mutates. When the enclosing refresh function // runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // // The inline case matters for correctness as much as the hop: a SYNC or // BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers // run, so a scope worker that tried to hop to the UI thread there would // deadlock. Capturing before those workers are spawned — inline, on the UI // thread — avoids that entirely. This is why BLOCK_UI (which always runs on the // UI thread, even from a worker caller) captures inline rather than hopping. func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { if fRunsOnUIThread { fn() return } wrapped := func() error { fn() return nil } if background { _ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped) } else { _ = self.c.GocuiGui().OnUIThreadAndWait(wrapped) } } // capturedFilesState holds the files refresh's context/model inputs, gathered // on the UI thread before the git work runs: the previous files list (to detect // resolved conflicts and drive the auto-stage), and whether untracked files are // force-shown. type capturedFilesState struct { prevFiles []*models.File forceShowUntracked bool } // captureFilesState reads the files refresh's inputs into an immutable snapshot. // It must run on the UI thread. func (self *RefreshHelper) captureFilesState() capturedFilesState { return capturedFilesState{ prevFiles: self.c.Model().Files, forceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), } } func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env refreshEnv, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { // If git thinks any of our files have inline merge conflicts, but they actually don't, // we stage them. // Note that if files with merge conflicts have both arisen and have been resolved // between refreshes, we won't stage them here. This is super unlikely though, // and this approach spares us from having to call `git status` twice in a row. // Although this also means that at startup we won't be staging anything until // we call git status again. pathsToStage := []string{} for _, file := range captured.prevFiles { if file.HasMergeConflicts { prevConflictFileCount++ } if file.HasInlineMergeConflicts { hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Path) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { pathsToStage = append(pathsToStage, file.Path) } } } if len(pathsToStage) > 0 { self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles) if err := self.c.Git().WorkingTree.StageFiles(pathsToStage, nil); err != nil { return err } } } files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, Background: env.background, }) conflictFileCount := 0 for _, file := range files { if file.HasMergeConflicts { conflictFileCount++ } } repoState := self.c.State().GetRepoState() workingTreeState := self.c.Git().Status.WorkingTreeState() if workingTreeState.None() { // No operation is in progress (any more), so forget that we started one. // This also covers an operation that was finished or aborted externally. repoState.SetMergeOrRebaseStartedInLazygit(false) } if workingTreeState.Any() && conflictFileCount == 0 { if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() { // The conflicts of an operation we started have just been resolved // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. self.onUIThreadUnlessRepoChanged(env, func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } } else { // Either there's no operation in progress any more, or new conflicts have // appeared. Either way, a "continue?" prompt we're showing is now stale // (e.g. the operation was continued or aborted outside lazygit), so // dismiss it rather than leave the user with a prompt that would fail. // Guard on the generation like the sibling PromptToContinueRebase // bounce above: if the repo was switched while this refresh was in // flight, a prompt showing now belongs to the new repo, so leave it be. self.onUIThreadUnlessRepoChanged(env, func() error { self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() return nil }) } self.onUIThreadUnlessRepoChanged(env, func() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles } } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) self.c.Contexts().Files.GetView().Subtitle = "" } self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files fileTreeViewModel.SetTree() return nil }) return nil } // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying // the reflogs array when changing contexts. // This method also manages two things: ReflogCommits and FilteredReflogCommits. // FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits // are used by the branches panel to obtain recency values for sorting. // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() // load does the git work on the worker and returns the new value for a // reflog slice, reading the existing slice (captured on the UI thread) for // the incremental fetch. The caller writes the result in the bounce. load := func(existing []*models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, error) { var lastReflogCommit *models.Commit if filterPath == "" && filterAuthor == "" && len(existing) > 0 { lastReflogCommit = existing[0] } commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. GetReflogCommits(captured.hashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return nil, err } if onlyObtainedNewReflogCommits { return append(commits, existing...), nil } return commits, nil } reflogCommits, err := load(captured.reflogCommits, "", "") if err != nil { return nil, err } filteredReflogCommits := reflogCommits if captured.filteringActive { filteredReflogCommits, err = load(captured.filteredReflogCommits, captured.filterPath, captured.filterAuthor) if err != nil { return nil, err } } self.onUIThreadUnlessRepoChanged(env, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and atomic with the list update. Setting the // selection doesn't scroll the view, so also reset the origin. if selectTopEntry { self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) } return nil }) self.refreshView(self.c.Contexts().ReflogCommits, env) return reflogCommits, nil } func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) { remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { return nil, err } self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 self.rebuildPullRequestsMap() if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { // if we didn't have PRs in the map before but now we do, we need to redraw the branches view self.refreshView(self.c.Contexts().Branches, env) } // we need to ensure our selected remote branches aren't now outdated if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil { // find remote now for _, remote := range remotes { if remote.Name == prevSelectedRemote.Name { self.c.Model().RemoteBranches = remote.Branches break } } } return nil }) self.refreshView(self.c.Contexts().Remotes, env) self.refreshView(self.c.Contexts().RemoteBranches, env) return remotes, nil } func (self *RefreshHelper) loadWorktrees() []*models.Worktree { worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) return []*models.Worktree{} } return worktrees } func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { worktrees := self.loadWorktrees() self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Worktrees = worktrees return nil }) // need to refresh branches because the branches view shows worktrees against // branches self.refreshView(self.c.Contexts().Branches, env) self.refreshView(self.c.Contexts().Worktrees, env) } func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv) { stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(filterPath) self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().StashEntries = stashEntries return nil }) self.refreshView(self.c.Contexts().Stash, env) } // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus(env refreshEnv) { workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() self.onUIThreadUnlessRepoChanged(env, func() error { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would // see stale values from before those bounces applied. currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh return nil } linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) self.c.SetViewContent(self.c.Views().Status, status) return nil }) } // refForLog returns the ref to log commits from, along with the bisect info it // read to decide that. The caller writes the bisect info to the model (in its // bounce) rather than refForLog doing it, so the model write stays on the UI // thread. func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { bisectInfo := self.c.Git().Bisect.GetInfo() if !bisectInfo.Started() { return "HEAD", bisectInfo } // need to see if our bisect's current commit is reachable from our 'new' ref. if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { return bisectInfo.GetNewHash(), bisectInfo } return bisectInfo.GetStartHash(), bisectInfo } func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // refreshView is called from the worker goroutine that drives async // refreshes, so bounce to the UI thread before mutating view content. Guard // on the generation like the model-update bounces do: if the repo was // switched while the refresh was in flight, its model write was already // dropped, so there's nothing fresh to render — and the captured context // belongs to the old repo's now-replaced context tree anyway. self.onUIThreadUnlessRepoChanged(env, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) self.c.PostRefreshUpdate(context) self.c.AfterLayout(func() error { // Re-applying the search must be done after re-rendering the view though, // so that the "x of y" status is shown correctly. // // Also, it must be done after layout, because otherwise FocusPoint // hasn't been called yet (see ListContextTrait.FocusLine), which means // that the scroll position might be such that the entire visible // content is outside the viewport. And this would cause problems in // searchModelCommits. self.searchHelper.ReApplySearch(context) return nil }) return nil }) } func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) { clearPullRequests := func() { self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return nil }) } githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes), self.c.Git().GitHub.GetAuthToken) if len(githubRemotes) == 0 { clearPullRequests() return } baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) if baseInfo == nil { clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { self.promptForBaseGithubRepo(githubRemotes, branches) } return } self.setGithubPullRequests(baseInfo, branches, env) } type githubRemoteInfo struct { remote *models.Remote serviceInfo hosting_service.ServiceInfo authToken string } func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote) []githubRemoteInfo { return lo.FilterMap(remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } serviceInfo, err := self.c.Git().HostingService.GetServiceInfo(remote.Urls[0]) if err != nil || serviceInfo.Provider != "github" { return githubRemoteInfo{}, false } return githubRemoteInfo{remote: remote, serviceInfo: serviceInfo}, true }) } // getAuthenticatedGithubRemotes drops remotes for which no auth token is // available and attaches the resolved token to the rest. Token lookups are // cached by host so that multiple remotes pointing at the same instance // (e.g. origin + a fork on github.com) only trigger one lookup. func getAuthenticatedGithubRemotes(githubRemotes []githubRemoteInfo, getAuthToken func(host string) string) []githubRemoteInfo { tokensByHost := map[string]string{} return lo.FilterMap(githubRemotes, func(info githubRemoteInfo, _ int) (githubRemoteInfo, bool) { host := info.serviceInfo.WebDomain token, cached := tokensByHost[host] if !cached { token = getAuthToken(host) tokensByHost[host] = token } if token == "" { return githubRemoteInfo{}, false } info.authToken = token return info, true }) } func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName string) *githubRemoteInfo { findRemoteByName := func(name string) *githubRemoteInfo { info, ok := lo.Find(githubRemotes, func(info githubRemoteInfo) bool { return info.remote.Name == name }) if !ok { return nil } return &info } if configuredRemoteName != "" { return findRemoteByName(configuredRemoteName) } if len(githubRemotes) == 1 { return &githubRemotes[0] } // Not sure if "upstream" is really a common convention for the name of the remote that PRs are // made against, but if it exists it's pretty likely to be the one we want. if info := findRemoteByName("upstream"); info != nil { return info } return nil } func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo, branches []*models.Branch) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingPullRequests, func(gocui.Task) error { if err := self.c.Git().GitHub.SetConfiguredBaseRemoteName(info.remote.Name); err != nil { self.c.Log.Error(err) } // This fetch runs on its own worker after the user picked a // base remote, so it's not part of a performRefresh and has no // ambient env; build a foreground one now, capturing the // current generation as the guard baseline. self.setGithubPullRequests(&info, branches, refreshEnv{generation: self.c.State().GetRepoGeneration()}) return nil }) }, } }) _ = self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.SelectRemoteRepository, Items: menuItems, OnCancel: func() error { if self.githubBaseRemotePromptDismissed == nil { self.githubBaseRemotePromptDismissed = make(map[string]bool) } self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] = true return nil }, }) } func (self *RefreshHelper) rebuildPullRequestsMap() { self.c.Model().PullRequestsMap = git_commands.GenerateGithubPullRequestMap( self.c.Model().PullRequests, self.c.Model().Branches, self.c.Model().Remotes, ) } func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, env refreshEnv) { if len(branches) == 0 { return } trackingBranches := lo.Filter(branches, func(branch *models.Branch, _ int) bool { return branch.IsTrackingRemote() }) branchNames := lo.Map(trackingBranches, func(branch *models.Branch, _ int) string { return branch.UpstreamBranch }) prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) if err != nil { self.c.Log.Error("error fetching pull requests from GitHub: " + err.Error()) return } self.savePullRequestsToCache(prs) self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their // own refreshes' bounces have applied. self.rebuildPullRequestsMap() self.c.PostRefreshUpdate(self.c.Contexts().Branches) return nil }) } func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest) { repoPath := self.c.Git().RepoPaths.RepoPath() cached := lo.Map(prs, func(pr *models.GithubPullRequest, _ int) config.CachedPullRequest { return config.CachedPullRequest{ HeadRefName: pr.HeadRefName, Number: pr.Number, Title: pr.Title, State: pr.State, Url: pr.Url, HeadRepositoryOwner: pr.HeadRepositoryOwner.Login, } }) appState := self.c.GetAppState() if appState.GithubPullRequests == nil { appState.GithubPullRequests = make(map[string][]config.CachedPullRequest) } appState.GithubPullRequests[repoPath] = cached self.c.SaveAppStateAndLogError() }