-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Expand file tree
/
Copy pathrefresh_helper.go
More file actions
1787 lines (1591 loc) · 68.9 KB
/
Copy pathrefresh_helper.go
File metadata and controls
1787 lines (1591 loc) · 68.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package helpers
import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands"
"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, false)
}
// RefreshBlockingInput is Refresh for handlers whose next keypress may depend
// on the state the refresh produces. See IGuiCommon.RefreshBlockingInput.
func (self *RefreshHelper) RefreshBlockingInput(options types.RefreshOptions) {
self.performRefresh(options, false, true)
}
// 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, false)
}
type refreshEnv struct {
// Whether everything this refresh dispatches uses the background task
// variants, which don't count towards lazygit being busy — so the refresh
// doesn't block switching repos. Set for refreshes initiated by a
// background routine, and for foreground ones that opted in via
// RefreshOptions.DontBlockRepoSwitch.
background bool
// Whether the refresh was initiated by an unattended background routine
// (RefreshOptions.Background) rather than by user activity. The files
// refresh uses this to decide whether git may take optional locks and
// persist its refreshed stat cache.
backgroundRoutine bool
// the repo generation captured when the refresh started
generation int
// the git command instance captured when the refresh started. The refresh
// workers run their git commands through this rather than reading the live
// instance: a repo switch mid-refresh replaces the live instance (and the
// process cwd), while this one keeps addressing the repo the refresh was
// started for (its commands are pinned to that repo's directory).
git *commands.GitCommand
// 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, blockInput bool) {
startTime := time.Now()
// A refresh from a worker blocks that worker until it's done; one from the
// UI thread returns immediately and finishes in the background.
syncOrAsync := "async"
if calledFromWorker {
syncOrAsync = "sync"
}
if options.Scope == nil {
self.c.Log.Infof("refreshing all scopes (%s)", syncOrAsync)
} else {
self.c.Log.Infof(
"refreshing the following scopes (%s): %s",
syncOrAsync,
strings.Join(getScopeNames(options.Scope), ","),
)
}
// 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")
}
if options.Then != nil && options.DontBlockRepoSwitch {
// Then is not generation-guarded, so if a switch crossed the refresh it
// would run against the newly switched-to repo. A refresh carrying a
// Then must keep blocking switches.
panic("a refresh with a Then callback must not set DontBlockRepoSwitch")
}
// A RefreshBlockingInput caller wants keyboard input withheld until the
// refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin
// the block synchronously here in the calling handler, so that no keypress
// can slip through before it; the finishing step ends it from a callback
// queued behind the refresh's own updates (see waitAndFinalize). Demos
// take the blocking inline path below and need none of this.
blockInputUntilDone := blockInput && !self.c.InDemo()
if blockInputUntilDone {
self.c.GocuiGui().BeginBlockingEvents()
}
// Capture the refresh's baseline once, here at the start: the repo
// generation that every scope's bounce is guarded against, and the git
// command instance the scopes run their commands through. The two are
// captured together on the UI thread so that they can't straddle a repo
// switch (which runs on the UI thread): pairing the old repo's instance
// with the new repo's generation would let a refresh compute data from
// the old repo and write it into the new repo's model unguarded. With a
// consistent pair, a switch-crossing refresh keeps running its commands
// against the repo it started in, and the generation guard drops its
// writes.
env := refreshEnv{
background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background,
}
self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git()
})
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, env)
wg := sync.WaitGroup{}
refresh := func(name string, f func()) {
wg.Add(1)
// Each scope runs on its own goroutine, joined by the wg.Wait in
// waitAndFinalize. They don't need to be registered as gocui tasks for
// repo-switch safety: performRefresh always runs under a task that stays
// busy until that wg.Wait returns — the calling worker's task when
// called from a worker, or the waitAndFinalize worker task when called
// from the UI thread (created before the triggering event's task ends,
// so there's no gap) — and that task already covers the whole refresh.
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(calledFromWorker, 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(calledFromWorker, 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(calledFromWorker, 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(calledFromWorker, 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(calledFromWorker, 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(calledFromWorker, 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(calledFromWorker, 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) {
// Fetching pull requests talks to the GitHub API over the network; on
// a bad connection that request can stall for a long time. It runs no
// git commands against the repo, and its model writes are guarded by
// the repo generation (a repo switch mid-fetch simply drops the
// result), so it is safe to run as a background task even when the
// enclosing refresh is a foreground one — a foreground task would
// block repo switching for as long as the request takes. The env copy
// makes the downstream UI-thread bounces background as well.
prEnv := env
prEnv.background = true
self.c.OnWorkerBackground(func(gocui.Task) error {
branchesAndRemotesWg.Wait()
t := time.Now()
// 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, prEnv)
self.c.Log.Infof("refreshed pull requests in %s", time.Since(t))
return nil
})
}
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() {
// Bounce onto the UI thread, like the staging panel above:
// RefreshPatchBuildingPanel reads the commit-files selection and
// sets the patch view's origin, neither of which may run off the UI
// thread. Guard on the generation so a repo switch mid-refresh drops
// it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() {
// Bounce onto the UI thread, like the staging and patch-building
// panels above: RefreshMergeState reads the current context and
// renders (or escapes) the merge-conflicts view, none of which may
// run off the UI thread.
self.onUIThreadUnlessRepoChanged(env, func() {
_ = self.mergeConflictsHelper.RefreshMergeState()
})
})
}
self.refreshStatus(env)
waitAndFinalize := func() {
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)
}
if blockInputUntilDone {
// Queued after the scopes' model bounces and Then, so by the time
// this runs — and the keys buffered during the refresh replay —
// the refreshed state is in place.
self.c.OnUIThread(func() error {
return self.c.GocuiGui().EndBlockingEvents()
})
}
self.c.Log.Infof("Refresh took %s", time.Since(startTime))
}
// waitAndFinalize blocks until every scope is done. Run it inline when we're
// already on a worker (or in a demo, for a deterministic single frame); when
// we're on the UI thread, dispatch it to a worker so it doesn't block the UI.
if calledFromWorker || self.c.InDemo() {
waitAndFinalize()
} else {
self.onWorker(env.background, func(t gocui.Task) error {
waitAndFinalize()
return nil
})
}
}
// 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], env refreshEnv) {
if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) {
return
}
snapshot, err := env.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]
})
}
// 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
filterRefs []string
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(),
filterRefs: self.c.Contexts().LocalCommits.GetFilterRefs(),
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(env refreshEnv) models.Ref {
if rebasedBranch := env.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 := env.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 := env.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(env)
refName, bisectInfo := self.refForLog(env)
commits, err := env.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,
FilterRefs: captured.filterRefs,
MainBranches: captured.mainBranches,
HashPool: captured.hashPool,
},
)
if err != nil {
return err
}
workingTreeState := env.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 := env.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{