From 00cef799ce36268cd198f0ae8ed22e6922fb4203 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:07:03 +0200 Subject: [PATCH 1/9] Show the inline status again when checking out a newly created remote branch Checking out a remote branch that has no local counterpart creates the local branch, refreshes, and then checks it out. The refresh exists so that CheckoutRef finds the new branch in the model and attaches an inline status to the branch item instead of showing a global waiting status. But since UI-thread refreshes stopped blocking, the checkout started before the refreshed branches had landed in the model, so the lookup failed and we always got the waiting status. Run the checkout from the refresh's Then, which is queued behind the model update. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refs_helper.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 675c332a0..0fcbeace3 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -157,12 +157,17 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN if err := self.c.Git().Branch.CreateWithUpstream(localBranchName, fullBranchName); err != nil { return err } - // Do a sync refresh to make sure the new branch is visible, - // so that we see an inline status when checking it out + // Refresh the branches and check out from Then, so that the + // new branch is already in the model when CheckoutRef looks + // it up; that's what makes it show an inline status on the + // branch rather than a global waiting status. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES}, + Then: func() error { + return checkout(localBranchName, true) + }, }) - return checkout(localBranchName, true) + return nil }, }, { From 2e653ceebaa69e3fd8d02843ba65166ac50760f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:08:56 +0200 Subject: [PATCH 2/9] Update comments that still describe the removed blocking refresh mode A few comments still reasoned in terms of SYNC vs ASYNC refreshes, a distinction that no longer exists: sync vs async is now derived from the calling thread. Restate them in terms of the current mechanisms (RefreshFromWorker blocking its worker, model updates being enqueued on the UI thread) without changing any behavior. Co-Authored-By: Claude Fable 5 --- .../helpers/inline_status_helper.go | 21 ++++++++++--------- pkg/gui/controllers/helpers/refresh_helper.go | 10 ++++----- pkg/gui/controllers/remotes_controller.go | 3 +-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index f2ca7ec17..814a11406 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -139,16 +139,17 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) { self.c.State().ClearItemOperation(opts.Item) // Re-render the context to remove the inline status now that the operation - // finished. Any refresh it triggered must be synchronous, not async: by the - // time we get here a synchronous refresh has already updated the model and - // queued its own re-render, and since UI-thread callbacks run in order, the - // render we queue here runs after it and draws the up-to-date model without - // the inline status. An async refresh might not have updated the model yet, - // so this render could briefly show the stale, pre-operation model: when - // pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind - // counts for a moment before the refresh replaced them with a green - // checkmark. (Operations that don't refresh at all are fine too: there's - // nothing stale to show, so this just drops the status.) + // finished. The operation must trigger its refresh via RefreshFromWorker + // before we get here: that call returns only once the refresh's model + // updates have been enqueued on the UI thread, and since UI-thread + // callbacks run in order, the render we queue here runs after them and + // draws the up-to-date model without the inline status. A refresh whose + // model updates aren't enqueued yet by this point would make this render + // briefly show the stale, pre-operation model: when pushing a branch, for + // example, it would flash the old ↑3↓7 ahead/behind counts for a moment + // before the refresh replaced them with a green checkmark. (Operations + // that don't refresh at all are fine too: there's nothing stale to show, + // so this just drops the status.) self.renderContext(opts.ContextKey) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 8ae62d9dd..a85c58cf2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1216,11 +1216,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // runs on the UI thread (calledFromWorker is false) 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. +// The inline case matters for correctness as much as the hop: OnUIThreadAndWait +// must not be called from the UI thread itself (it would park the thread +// waiting for a callback that only it can run), and capturing inline also +// guarantees the snapshot reflects the state at the moment Refresh was called, +// before the calling handler regains control and can mutate it. func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) { if !calledFromWorker { fn() diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 76bd16bb1..cd05e5ff9 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -159,8 +159,7 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl // Refresh the remotes so that we can select the new one. The remotes model // update is bounced onto the UI thread, so the selection (which reads // Model.Remotes) has to run in Then; reading it inline here would see the - // previous model. Loading remotes is not expensive, so a sync refresh is - // affordable. + // previous model. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, Then: func() error { From 7a902b56cc49dc908e25a76b894fc38cb1923590 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 12:49:36 +0200 Subject: [PATCH 3/9] Add a way for integration tests to press keys in rapid succession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test driver waits for lazygit to become idle after every keypress, so tests could never exercise what happens when a key arrives while the previous key's processing is still in flight — for example while the refresh triggered by the previous key hasn't updated the model yet. Real users type faster than that all the time. PressRapidly injects all its keys back to back and waits for idle only once at the end, so the second and later keys are queued before the first one's processing has finished. The next commit uses this to demonstrate a bug in exactly that scenario. Co-Authored-By: Claude Fable 5 --- pkg/gui/gui_driver.go | 26 ++++++++++++++++------- pkg/integration/components/test_driver.go | 10 +++++++++ pkg/integration/components/test_test.go | 4 ++++ pkg/integration/components/view_driver.go | 13 ++++++++++++ pkg/integration/types/types.go | 4 ++++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 7bd31d93d..74a8109a7 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -25,17 +25,27 @@ type GuiDriver struct { var _ integrationTypes.GuiDriver = &GuiDriver{} func (self *GuiDriver) PressKey(keyStr string) { + self.PressKeysRapidly(keyStr) +} + +// PressKeysRapidly presses the given keys in immediate succession, waiting for +// lazygit to become idle only after the last one. Keys pressed this way can +// arrive while the previous key's processing is still in flight, like a user +// typing faster than lazygit handles the input. +func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { self.CheckAllToastsAcknowledged() - key, ok := config.KeyFromLabel(keyStr) - if !ok { - self.Fail("Unrecognized key: " + keyStr) - } + for _, keyStr := range keyStrs { + key, ok := config.KeyFromLabel(keyStr) + if !ok { + self.Fail("Unrecognized key: " + keyStr) + } - self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( - tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), - 0, - )) + self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( + tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), + 0, + )) + } self.waitTillIdle() } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 376b0f4d6..42ce8ac35 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "strings" "time" "github.com/jesseduffield/lazygit/pkg/config" @@ -42,6 +43,15 @@ func (self *TestDriver) pressFast(keyStr string) { self.Wait(self.inputDelay / 5) } +// presses the keys in immediate succession, without waiting for lazygit to +// become idle in between, to simulate a user typing faster than lazygit +// processes the input +func (self *TestDriver) pressRapidly(keyStrs []string) { + self.SetCaption(fmt.Sprintf("Pressing %s", strings.Join(keyStrs, ", "))) + self.gui.PressKeysRapidly(keyStrs...) + self.Wait(self.inputDelay) +} + func (self *TestDriver) click(x, y int) { self.SetCaption(fmt.Sprintf("Clicking %d, %d", x, y)) self.gui.Click(x, y) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index e7fd0b66a..8fd4417ea 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -30,6 +30,10 @@ func (self *fakeGuiDriver) PressKey(key string) { self.pressedKeys = append(self.pressedKeys, key) } +func (self *fakeGuiDriver) PressKeysRapidly(keys ...string) { + self.pressedKeys = append(self.pressedKeys, keys...) +} + func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index df4b9d7d8..920c610be 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -454,6 +454,19 @@ func (self *ViewDriver) PressFast(key config.Keybinding) *ViewDriver { return self } +// Presses the given keys in immediate succession, without waiting for lazygit +// to become idle in between (Press waits after every key). Use this to +// simulate a user typing faster than lazygit processes the input. +func (self *ViewDriver) PressRapidly(keys ...config.Keybinding) *ViewDriver { + self.IsFocused() + + self.t.pressRapidly(lo.Map(keys, func(key config.Keybinding, _ int) string { + return key[0] + })) + + return self +} + func (self *ViewDriver) Click(x, y int) *ViewDriver { offsetX, offsetY, _, _ := self.getView().Dimensions() diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 34ce499cc..12009315a 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -23,6 +23,10 @@ type IntegrationTest interface { // this is the interface through which our integration tests interact with the lazygit gui type GuiDriver interface { PressKey(string) + // Like PressKey, but presses several keys in immediate succession, waiting + // for lazygit to become idle only after the last one. Use it to simulate a + // user typing faster than lazygit processes the input. + PressKeysRapidly(...string) Click(int, int) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) From 963db76ab62700ddd32ddfd14e0e16efe30601be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 12:54:24 +0200 Subject: [PATCH 4/9] Add test showing that a rapid second keypress acts on a stale staging panel Pressing space twice in quick succession in the staging panel is supposed to stage two hunks: the refresh triggered by the first press rebuilds the panel's diff and moves the selection to the next stageable hunk, and the second press stages that. Since we made UI-thread refreshes non-blocking, the second press is handled as soon as it arrives, while that refresh is still in flight. It then reads the stale pre-refresh diff, builds the first hunk's patch again, and git apply fails with 'patch does not apply' because those lines are already in the index. The test documents this currently broken behavior; the fix comes next. Co-Authored-By: Claude Fable 5 --- .../stage_hunks_with_rapid_keypresses.go | 69 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go new file mode 100644 index 000000000..74dbb4b6f --- /dev/null +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -0,0 +1,69 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second space is pressed before the refresh triggered by the first one +// has updated the staging panel. That refresh is what moves the selection to +// the next hunk, so the second press must not be handled until it has landed; +// handling it earlier would try to stage the first hunk a second time. +var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage two hunks with two space presses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = true + }, + SetupRepo: func(shell *Shell) { + // Use 7 context lines between the two change blocks so that git creates + // two separate hunks. + shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") + shell.Commit("one") + + shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + PressEnter() + + t.Views().Staging(). + IsFocused(). + PressRapidly(keys.Universal.Select, keys.Universal.Select) + + /* EXPECTED: + t.Views().StagingSecondary(). + IsFocused(). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + ACTUAL: */ + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("patch does not apply")). + Confirm() + + t.Views().Staging(). + IsFocused(). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + + t.Views().StagingSecondary(). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8213d5159..2189d3506 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -403,6 +403,7 @@ var tests = []*components.IntegrationTest{ staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, staging.StageHunks, + staging.StageHunksWithRapidKeypresses, staging.StageLines, staging.StagePartialBlockOfChangesFirstLines, staging.StagePartialBlockOfChangesLastLines, From b96b8a97534d513dd774d2826782ee833b2cfa36 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:02:36 +0200 Subject: [PATCH 5/9] Add RefreshBlockingInput to buffer keypresses until a refresh has landed A refresh from the UI thread returns immediately and applies its model and view updates as queued UI-thread callbacks. A key pressed before those have run is handled against the stale, pre-refresh state. For most keys that's harmless, but some handlers turn that state into git commands: pressing space twice in quick succession in the staging panel builds the second patch from the already-applied diff and fails with 'patch does not apply', because the refresh after the first press is what moves the selection to the next stageable hunk. Notably, this is not just a regression of the recent change that made UI-thread refreshes non-blocking; the window was merely much narrower before. A blocking refresh parked the UI thread while the scopes' bounces were queued, and the event loop drains pending keyboard input with priority over queued user events, so a key pressed during the blocked window still beat the queued state updates. The guarantee that the next keypress sees post-refresh state had already ended when the scopes' state updates moved from worker-side mutex-guarded writes to UI-thread bounces. Fix it with the input-blocking mechanism we already use for commit surgery, exposed as a new RefreshBlockingInput entry point: it begins blocking events synchronously in the calling handler, and ends the block from a callback that the finishing step queues behind the refresh's own updates. Keys pressed while the refresh is in flight are buffered and replayed, in order, against the fully refreshed state; since a replayed key's handler re-enters this same path, a burst of keypresses applies sequentially, each one seeing the previous one's refresh. Unlike the old blocking refreshes, this doesn't freeze the UI thread: rendering, spinners, resizing, and mouse scrolling keep working while input is withheld. Blocking input is opt-in per call site rather than the default for all UI-thread refreshes, because most refreshes (the focus-in and startup refreshes, say) don't produce state that the next keypress depends on, and blocking on them would delay typing for no reason. It should also be limited to quick, narrow-scoped refreshes: a full refresh, or any scope that pulls in COMMITS, can take very long in large repos and should usually not hold up input. The staging panel's stage/discard/edit-hunk refreshes use it now. --- pkg/gui/controllers/helpers/refresh_helper.go | 32 +++++++++++++++++-- pkg/gui/controllers/staging_controller.go | 9 ++++-- pkg/gui/gui_common.go | 4 +++ pkg/gui/types/common.go | 11 +++++++ .../stage_hunks_with_rapid_keypresses.go | 19 ----------- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a85c58cf2..cf097b67e 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -81,14 +81,20 @@ func NewRefreshHelper( } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { - self.performRefresh(options, false) + 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) + self.performRefresh(options, true, false) } type refreshEnv struct { @@ -159,7 +165,7 @@ func (self *refreshBounceBatch) close() []func() { return self.funcs } -func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { +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 @@ -192,6 +198,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr 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 @@ -498,6 +515,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr 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)) } diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acda..505a07fc4 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -229,7 +229,10 @@ func (self *StagingController) applySelectionAndRefresh(reverse bool) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input until the refresh has landed: it rebuilds the staging panel + // and moves the selection to the next stageable change, and a quick second + // keypress must act on that, not on the stale pre-refresh diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } @@ -284,7 +287,9 @@ func (self *StagingController) EditHunkAndRefresh() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input like applySelectionAndRefresh does; the refresh rebuilds the + // staging panel from the post-edit diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 80b2b9ded..e7b14ba04 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) { self.gui.helpers.Refresh.Refresh(opts) } +func (self *guiCommon) RefreshBlockingInput(opts types.RefreshOptions) { + self.gui.helpers.Refresh.RefreshBlockingInput(opts) +} + func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { self.gui.helpers.Refresh.RefreshFromWorker(opts) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6d11e29db..87bd9ef50 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -29,6 +29,17 @@ type IGuiCommon interface { LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) + // Like Refresh, but withholds keyboard input until the refreshed state is + // in place: keys pressed while the refresh is in flight are buffered and + // replayed once its model and view updates have run, instead of being + // handled against the stale, pre-refresh state. Use it when the very next + // keypress may depend on what the refresh produces — e.g. staging a hunk, + // where the refresh moves the selection to the next stageable hunk that + // the next press is meant to stage. Keep it to quick, narrow-scoped + // refreshes: one that includes COMMITS (or refreshes everything) can take + // very long in large repos and should usually not block input unless + // there's a very good reason (switching repos is one such example). + RefreshBlockingInput(RefreshOptions) // Like Refresh, but for callers running on a worker goroutine (e.g. inside // a WithWaitingStatus handler) rather than the UI thread. The refresh // captures the model/context state it needs on the UI thread before doing diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go index 74dbb4b6f..5b41073e2 100644 --- a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -36,7 +36,6 @@ var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). PressRapidly(keys.Universal.Select, keys.Universal.Select) - /* EXPECTED: t.Views().StagingSecondary(). IsFocused(). ContainsLines( @@ -47,23 +46,5 @@ var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ Contains("+3b"), Contains("+4b"), ) - ACTUAL: */ - t.ExpectPopup().Alert(). - Title(Equals("Error")). - Content(Contains("patch does not apply")). - Confirm() - - t.Views().Staging(). - IsFocused(). - ContainsLines( - Contains("+3b"), - Contains("+4b"), - ) - - t.Views().StagingSecondary(). - ContainsLines( - Contains("+1b"), - Contains("+2b"), - ) }, }) From 200042a57cc9637d2ae756e9aa0a07673faf52a7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:29:47 +0200 Subject: [PATCH 6/9] Add test showing that rapidly moving a rebase todo twice moves the wrong todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a todo up or down rewrites the todo file and advances the selection synchronously, but the commits model is only rebuilt by the refresh, which finishes in the background. A second keypress arriving before that reads the pre-move model at the advanced selection index — that's the todo the first move swapped with, so the second press moves that one back instead of moving the selected todo further. Two rapid presses (e.g. from holding the key down) thus amount to a net no-op. The two presses also spawn two racing refreshes whose model updates can land in either order, so the todo list can even end up disagreeing with the todo file. That's why the test continues the rebase and asserts the resulting commit order instead of the displayed list: the rebase replays the file, which is deterministic. The test documents this currently broken behavior; the fix comes next. Co-Authored-By: Claude Fable 5 --- .../move_todo_down_with_rapid_keypresses.go | 68 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 69 insertions(+) create mode 100644 pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go new file mode 100644 index 000000000..548d924f4 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -0,0 +1,68 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second keypress arrives before the refresh triggered by the first one +// has rebuilt the commits model. The handler reads the selected todo from the +// model at the already-advanced selection index, so with the stale, pre-move +// model it grabs the todo the first move swapped with and moves that one back +// down — turning the two presses into a net no-op instead of moving the +// selected todo down two slots. This is what happens when holding down the +// move-down key to move a todo several slots. +// +// We continue the rebase and assert the resulting commit order rather than +// asserting the todo list, because the two presses also spawn two racing +// refreshes whose updates can land in either order, so what the todo list +// shows in the broken state is not deterministic (it can even disagree with +// the todo file). The rebase replays what's in the file. +var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a todo down two slots with two keypresses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(4) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + NavigateToLine(Contains("commit-01")). + Press(keys.Universal.Edit). + Lines( + Contains("--- Pending rebase todos ---"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("--- Commits ---"), + Contains("commit-01").IsSelected(), + ). + NavigateToLine(Contains("commit-04")). + PressRapidly(keys.Commits.MoveDownCommit, keys.Commits.MoveDownCommit). + Tap(func() { + t.Common().ContinueRebase() + }). + /* EXPECTED: + Lines( + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04"), + Contains("commit-01"), + ) + ACTUAL: */ + Lines( + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2189d3506..1bb06741f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -310,6 +310,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.Move, interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase, interactive_rebase.MoveInRebase, + interactive_rebase.MoveTodoDownWithRapidKeypresses, interactive_rebase.MoveUpdateRefTodo, interactive_rebase.MoveWithCustomCommentChar, interactive_rebase.OutsideRebaseRangeSelect, From 055184f99772dc360b4dacb23edf0113dfadb86d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:31:36 +0200 Subject: [PATCH 7/9] Block input while the refresh after moving a rebase todo is in flight Moving a todo rewrites the todo file and advances the selection synchronously, but the commits model is only rebuilt by the refresh. A second press arriving before that grabs the swapped-with todo from the stale model at the advanced index and moves it back, so holding the key to move a todo several slots misbehaved. Use RefreshBlockingInput so the second press is buffered and replayed once the moved todo list is in place. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/local_commits_controller.go | 8 ++++++-- .../move_todo_down_with_rapid_keypresses.go | 8 -------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 2da502b79..708f8fc28 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -741,7 +741,10 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().MoveSelection(1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input until the refresh has landed: a quick second press must + // read the moved todo from the refreshed model, not grab whatever the + // advanced selection index points at in the stale one. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -777,7 +780,8 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().MoveSelection(-1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input for the same reason as in moveDown. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go index 548d924f4..16e58e18e 100644 --- a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -50,19 +50,11 @@ var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.Common().ContinueRebase() }). - /* EXPECTED: Lines( Contains("commit-03"), Contains("commit-02"), Contains("commit-04"), Contains("commit-01"), ) - ACTUAL: */ - Lines( - Contains("commit-04"), - Contains("commit-03"), - Contains("commit-02"), - Contains("commit-01"), - ) }, }) From fc975f32a8d84ab1083a1e526dcdedcd961974fd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:33:39 +0200 Subject: [PATCH 8/9] Block input while the refresh after a stash operation is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popping or dropping a stash shifts the indices of the entries below it, and renaming re-creates the stash at the top, shifting all the others. The stash model is only rebuilt by the refresh, which finishes in the background, so acting on the next entry in quick succession — pressing the key, confirming the popup, and pressing again right away — reads the stale pre-operation indices and targets the wrong stash. Note that the confirmation popup is no protection here: the race starts when the confirm handler runs, and the next keypress can easily beat the refresh. Use RefreshBlockingInput so a quick follow-up keypress is buffered and replayed once the refreshed stash list is in place. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/stash_controller.go | 30 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index a2b7e9e97..d01fc8dbf 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,13 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.DropStash) - // Refresh once at the end rather than after each drop: an async - // refresh from the UI thread finishes in the background, so firing - // one per iteration lets the workers race and an earlier, stale - // result can land last. The indices are captured up front and we - // drop highest-first, so the remaining lower indices stay valid - // without an intervening refresh. - defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // Refresh once at the end rather than after each drop: a refresh + // from the UI thread finishes in the background, so firing one per + // iteration lets the workers race and an earlier, stale result can + // land last. The indices are captured up front and we drop + // highest-first, so the remaining lower indices stay valid without + // an intervening refresh. Block input until the refresh has + // landed, so that dropping the next entry in quick succession + // (confirming and pressing the key again right away) sees the + // refreshed list and not the stale, pre-drop indices. + defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { @@ -192,7 +195,11 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) } func (self *StashController) postStashRefresh() { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + // Block input until the refresh has landed: popping shifts the indices of + // the remaining stash entries, and acting on the next entry in quick + // succession (confirming the popup and pressing the key again right away) + // must see the refreshed list, or it would target the wrong stash. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { @@ -214,12 +221,15 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr self.c.LogAction(self.c.Tr.Actions.RenameStash) err := self.c.Git().Stash.Rename(stashEntry.Index, response) if err != nil { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return err } self.context().SetSelection(0) // Select the renamed stash self.context().FocusLine(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // Renaming re-creates the stash at the top, shifting the other + // entries' indices; block input so that a quick next action sees + // the refreshed list rather than the stale indices. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return nil }, AllowEmptyInput: true, From 975da9b8a9bd8186ca353e9cddc4bd4cd83c4ecd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 20:38:41 +0200 Subject: [PATCH 9/9] Block input and batch UI updates when switching repos On startup we don't want to block input during the initial refresh (it should be possible to press, say, `4` to jump to the commits panel right after startup without a delay), and we also want panels to show their contents as soon as possible; it doesn't matter so much that it's not in sync, we go from empty to populated here. However, when switching repos it can be confusing that some panels that are slow to update still show the old repo's data while others already show the new one's data, so update the UI only when everything is ready, and also block input to prevent accidentally trying to act on the old, stale data. --- pkg/gui/gui.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8776040e7..912d46567 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1103,12 +1103,31 @@ func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error { return err } +var isFirstRefreshAfterStartup = true + func (gui *Gui) loadNewRepo() error { if err := gui.updateRecentRepoList(); err != nil { return err } - gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) + // On startup we don't want to block input during the initial refresh (it + // should be possible to press, say, `4` to jump to the commits panel right + // after startup without a delay), and we also want panels to show their + // contents as soon as possible; it doesn't matter so much that it's not in + // sync, we go from empty to populated here. However, when switching repos + // it can be confusing that some panels that are slow to update still show + // the old repo's data while others already show the new one's data, so + // update the UI only when everything is ready, and also block input to + // prevent accidentally trying to act on the old, stale data. + options := types.RefreshOptions{DontBlockRepoSwitch: true} + refresh := gui.c.Refresh + if isFirstRefreshAfterStartup { + isFirstRefreshAfterStartup = false + } else { + options.BatchUIUpdates = true + refresh = gui.c.RefreshBlockingInput + } + refresh(options) if err := gui.os.UpdateWindowTitle(); err != nil { return err