mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
This was useful when there was a BLOCK_UI mode where f() was called differently, but now we no longer need it. I'm making this change as a separate commit because folding it into the previous one (which would conceptually have made sense) would have made that diff unreadable because of the indentation change. The variable `fRunsOnUIThread` and its comment no longer make sense now; we'll clean this up next. The diff is best viewed with --ignore-all-space.
1688 lines
63 KiB
Go
1688 lines
63 KiB
Go
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
|
|
|
|
// When non-nil, each scope's UI-thread bounce is collected here instead of
|
|
// being dispatched as it's produced, so they can all be applied in a single
|
|
// frame once the whole refresh is done (see RefreshOptions.BatchUIUpdates).
|
|
// Held by pointer so the copies of env that flow through the scope functions
|
|
// all share the one batch.
|
|
batch *refreshBounceBatch
|
|
}
|
|
|
|
// refreshBounceBatch collects the UI-thread bounces of a batched refresh so they
|
|
// can be applied together in one frame rather than one scope at a time. The
|
|
// scopes run on separate worker goroutines and add concurrently, hence the
|
|
// mutex. Once the refresh starts flushing it closes the batch, so that any
|
|
// bounces enqueued afterwards — the nested ones a flushed bounce produces in
|
|
// turn, e.g. scrolling the selection into view — are dispatched immediately as
|
|
// ordinary follow-ups instead of being collected into a batch that nothing
|
|
// will drain.
|
|
type refreshBounceBatch struct {
|
|
mutex deadlock.Mutex
|
|
funcs []func()
|
|
closed bool
|
|
}
|
|
|
|
// add collects f and returns true. Once the batch is closed it collects nothing
|
|
// and returns false, telling the caller to dispatch f immediately instead.
|
|
func (self *refreshBounceBatch) add(f func()) bool {
|
|
self.mutex.Lock()
|
|
defer self.mutex.Unlock()
|
|
|
|
if self.closed {
|
|
return false
|
|
}
|
|
self.funcs = append(self.funcs, f)
|
|
return true
|
|
}
|
|
|
|
// close marks the batch flushed and returns everything collected so far.
|
|
func (self *refreshBounceBatch) close() []func() {
|
|
self.mutex.Lock()
|
|
defer self.mutex.Unlock()
|
|
|
|
self.closed = true
|
|
return self.funcs
|
|
}
|
|
|
|
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 (Refresh); a
|
|
// refresh initiated from a worker (RefreshFromWorker) runs f on that worker.
|
|
// This decides whether a scope capture runs inline or has to hop (see
|
|
// captureOnUIThread).
|
|
fRunsOnUIThread := !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. 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")
|
|
}
|
|
|
|
// 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(),
|
|
}
|
|
if options.BatchUIUpdates {
|
|
env.batch = &refreshBounceBatch{}
|
|
}
|
|
|
|
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() {
|
|
self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{})
|
|
})
|
|
})
|
|
}
|
|
|
|
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 env.batch != nil {
|
|
// Apply all the scopes' collected bounces in a single UI-thread task,
|
|
// so they land in one frame: gocui drains every queued event before it
|
|
// redraws, so one task means one repaint. Bounces enqueued from within
|
|
// these (see refreshBounceBatch) run as ordinary follow-ups.
|
|
bounces := env.batch.close()
|
|
self.onUIThread(env.background, func() error {
|
|
for _, bounce := range bounces {
|
|
bounce()
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
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() {
|
|
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
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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() {
|
|
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() {
|
|
self.c.Contexts().LocalCommits.FocusLine(true)
|
|
})
|
|
}
|
|
})
|
|
|
|
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() {
|
|
self.c.Model().SubCommits = commits
|
|
self.RefreshAuthors(commits)
|
|
})
|
|
|
|
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() {
|
|
self.c.Model().CommitFiles = files
|
|
self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree()
|
|
})
|
|
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() {
|
|
self.c.Model().Commits = updatedCommits
|
|
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
|
|
})
|
|
|
|
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() {
|
|
self.c.Model().Tags = tags
|
|
})
|
|
|
|
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() {
|
|
self.c.Contexts().Branches.HandleRender()
|
|
self.refreshStatus(env)
|
|
})
|
|
})
|
|
if err != nil {
|
|
self.c.Log.Error(err)
|
|
}
|
|
|
|
var worktrees []*models.Worktree
|
|
if refreshWorktrees {
|
|
worktrees = self.loadWorktrees()
|
|
}
|
|
|
|
self.onUIThreadUnlessRepoChanged(env, func() {
|
|
// 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
|
|
}
|
|
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()
|
|
})
|
|
|
|
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()) {
|
|
wrapper := func() {
|
|
if self.c.State().GetRepoGeneration() != env.generation {
|
|
return
|
|
}
|
|
f()
|
|
}
|
|
|
|
// A batched refresh collects its bounces and fires them together at the end
|
|
// (see refreshBounceBatch); add reports false once the batch is flushing, so
|
|
// bounces enqueued from within a flushed bounce dispatch immediately.
|
|
if env.batch != nil && env.batch.add(wrapper) {
|
|
return
|
|
}
|
|
|
|
self.onUIThread(env.background, func() error { wrapper(); return nil })
|
|
}
|
|
|
|
// 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 refresh
|
|
// initiated on the UI thread parks that 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.
|
|
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() {
|
|
// The merge-conflicts scope of this refresh also notices that
|
|
// the conflicts are gone and escapes from the merge conflicts
|
|
// view to the files context (see RefreshMergeState), but it
|
|
// runs concurrently with us, and its escape refuses to push
|
|
// the files context over a popup. So if our prompt opens
|
|
// first, the escape does nothing, and closing the prompt
|
|
// would land the user in the dead merge conflicts view.
|
|
// Escape it ourselves before opening the prompt, so that the
|
|
// prompt always opens on top of the files context.
|
|
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
|
|
self.mergeConflictsHelper.ResetMergeState()
|
|
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
|
|
}
|
|
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() {
|
|
self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing()
|
|
})
|
|
}
|
|
|
|
self.onUIThreadUnlessRepoChanged(env, func() {
|
|
// 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
|
|
}
|
|
|
|
// 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() {
|
|
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)
|
|
}
|
|
})
|
|
|
|
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() {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
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() {
|
|
self.c.Model().Worktrees = worktrees
|
|
})
|
|
|
|
// 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() {
|
|
self.c.Model().StashEntries = stashEntries
|
|
})
|
|
|
|
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() {
|
|
// 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
|
|
}
|
|
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)
|
|
})
|
|
}
|
|
|
|
// 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() {
|
|
// 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
|
|
})
|
|
})
|
|
}
|
|
|
|
func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) {
|
|
clearPullRequests := func() {
|
|
self.onUIThreadUnlessRepoChanged(env, func() {
|
|
self.c.Model().PullRequests = nil
|
|
self.c.Model().PullRequestsMap = 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() {
|
|
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)
|
|
})
|
|
}
|
|
|
|
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()
|
|
}
|