From 2420fc7b76f52ee67c000b0a98248c3e50b88c2d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:24:18 +0200 Subject: [PATCH 01/94] Lock the status list when reading it GetStatusString and HasStatus read the statuses slice without holding the mutex that addStatus and removeStatus take when they mutate it. The readers run on the spinner-render worker (which polls GetStatusString every frame) while removeStatus fires from the waiting-status and toast-expiry goroutines, so the unguarded reads race the concurrent writes. Take the mutex in the readers too. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/status/status_manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/gui/status/status_manager.go b/pkg/gui/status/status_manager.go index 2f822c1ee..414568a69 100644 --- a/pkg/gui/status/status_manager.go +++ b/pkg/gui/status/status_manager.go @@ -70,6 +70,9 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind) } func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) { + self.mutex.Lock() + defer self.mutex.Unlock() + if len(self.statuses) == 0 { return "", gocui.ColorDefault } @@ -81,6 +84,9 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin } func (self *StatusManager) HasStatus() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + return len(self.statuses) > 0 } From 1268a589d6996e3fac012907203efc59ab0936f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 08:27:35 +0200 Subject: [PATCH 02/94] Write the command log on the UI thread LogAction and LogCommand are called from git worker goroutines (every command a worker runs logs itself, and controllers log an action before kicking off their worker), where they set the Extras view's Autoscroll flag and append to GuiLog while the UI thread reads both when it lays out and draws the view. Bounce the writes onto the UI thread instead. Use the background variant so the bounce doesn't count towards lazygit being busy: writing the command log is incidental display work, and a foreground task would let an in-flight log write refuse a concurrent repo switch (the same reason view-buffer renders and toasts are backgrounded). Ordering between successive log calls is preserved by the bounce FIFO. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/command_log_panel.go | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 8f2e06b98..6f7976c3e 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -27,10 +27,20 @@ func (gui *Gui) LogAction(action string) { return } - gui.Views.Extras.Autoscroll = true + // LogAction and LogCommand are called both from the UI thread and from git + // worker goroutines, so bounce the writes onto the UI thread: they touch the + // view's autoscroll flag and the GuiLog slice, which the layout/draw code + // reads. Ordering between successive log calls is preserved by the FIFO the + // bounce enqueues onto. It's a background bounce because writing the command + // log is incidental display work that must not count towards lazygit being + // busy (otherwise it could block a repo switch). + gui.onUIThreadBackground(func() error { + gui.Views.Extras.Autoscroll = true - gui.GuiLog = append(gui.GuiLog, action) - fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action)) + gui.GuiLog = append(gui.GuiLog, action) + fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action)) + return nil + }) } func (gui *Gui) LogCommand(cmdStr string, commandLine bool) { @@ -38,17 +48,23 @@ func (gui *Gui) LogCommand(cmdStr string, commandLine bool) { return } - gui.Views.Extras.Autoscroll = true - textStyle := theme.DefaultTextColor if !commandLine { // if we're not dealing with a direct command that could be run on the command line, // we style it differently to communicate that textStyle = style.FgMagenta } - gui.GuiLog = append(gui.GuiLog, cmdStr) indentedCmdStr := " " + strings.ReplaceAll(cmdStr, "\n", "\n ") - fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr)) + + // See the comment in LogAction: bounce onto the UI thread since we may be + // called from a git worker, in the background so it can't block a repo switch. + gui.onUIThreadBackground(func() error { + gui.Views.Extras.Autoscroll = true + + gui.GuiLog = append(gui.GuiLog, cmdStr) + fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr)) + return nil + }) } func (gui *Gui) printCommandLogHeader() { From cbf220c49781bb78f96820b24b68b8e76a3d025b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:12:49 +0200 Subject: [PATCH 03/94] Read lines based on scroll position instead of a fixed per-notch delta When scrolling a lazy-loaded view (a diff in the main view, the command log, etc.), we top up the view's line buffer by reading more lines from the still-running task. This was driven by asking the task to read a fixed number of *additional* lines on every scroll event, which had two problems: - It was decoupled from the scroll position. Scrolling down, back up, and down again re-read lines that had already been read, so the buffer crept towards the end of the input regardless of where the user actually scrolled. - A single wheel notch only bought a single notch worth of runway, so fast scrolling constantly outran the reader and had to wait for the next read (and re-render) on every notch. Make ReadLines take an absolute target total instead of a delta: the task tracks how many lines it has read and only reads the shortfall, so requests are idempotent. Callers now ask to fill the viewport at the current scroll position plus a few screenfuls of read-ahead, which gives scrolling enough runway to stay smooth. The four call sites all wanted the same "fill this view" computation, so consolidate them into a single ReadLinesToFillView helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/vertical_scroll_controller.go | 8 ++----- .../controllers/view_selection_controller.go | 7 +----- pkg/gui/global_handlers.go | 8 ++----- pkg/gui/gui.go | 17 +++++++++++++ pkg/gui/gui_common.go | 4 ++++ pkg/gui/layout.go | 13 ++++++---- pkg/gui/types/common.go | 4 ++++ pkg/tasks/tasks.go | 24 +++++++++++++++---- 8 files changed, 58 insertions(+), 27 deletions(-) diff --git a/pkg/gui/controllers/vertical_scroll_controller.go b/pkg/gui/controllers/vertical_scroll_controller.go index 1db9bb76e..84e312c1d 100644 --- a/pkg/gui/controllers/vertical_scroll_controller.go +++ b/pkg/gui/controllers/vertical_scroll_controller.go @@ -66,12 +66,8 @@ func (self *VerticalScrollController) HandleScrollUp() error { } func (self *VerticalScrollController) HandleScrollDown() error { - scrollHeight := self.c.UserConfig().Gui.ScrollHeight - self.context.GetViewTrait().ScrollDown(scrollHeight) - - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadLines(scrollHeight) - } + self.context.GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight) + self.c.ReadLinesToFillView(self.context.GetView()) return nil } diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 31cbd3695..1a97a9a30 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -50,17 +50,12 @@ func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsO } func (self *ViewSelectionController) handleLineChange(delta int) { - if delta > 0 { - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadLines(delta) - } - } - v := self.Context().GetView() if delta < 0 { v.ScrollUp(-delta) } else { v.ScrollDown(delta) + self.c.ReadLinesToFillView(v) } } diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go index 3c4896af4..a5e59a84e 100644 --- a/pkg/gui/global_handlers.go +++ b/pkg/gui/global_handlers.go @@ -17,12 +17,8 @@ func (gui *Gui) scrollUpView(view *gocui.View) { } func (gui *Gui) scrollDownView(view *gocui.View) { - scrollHeight := gui.c.UserConfig().Gui.ScrollHeight - view.ScrollDown(scrollHeight) - - if manager := gui.getViewBufferManagerForView(view); manager != nil { - manager.ReadLines(scrollHeight) - } + view.ScrollDown(gui.c.UserConfig().Gui.ScrollHeight) + gui.readLinesToFillView(view) } func (gui *Gui) scrollUpMain() error { diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index d77673e9c..1e6e8f9c3 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -691,6 +691,23 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM return manager } +// When scrolling a lazy-loaded view, we read enough lines to fill the viewport +// plus this many extra screenfuls, so that further scrolling has some runway +// and doesn't have to block on reading (and re-rendering) more lines on every +// wheel notch. +const scrollReadAheadScreenfuls = 3 + +// readLinesToFillView reads enough lines into the view's buffer to cover +// everything currently scrolled into view, plus a few screenfuls of read-ahead. +// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer +// already extends far enough this does nothing. +func (gui *Gui) readLinesToFillView(view *gocui.View) { + if manager := gui.getViewBufferManagerForView(view); manager != nil { + viewportBottom := view.OriginY() + view.InnerHeight() + manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight()) + } +} + func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] { result := utils.NewThreadSafeMap[string, string]() diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index c8de7545d..69ec44781 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -165,6 +165,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View return self.gui.getViewBufferManagerForView(view) } +func (self *guiCommon) ReadLinesToFillView(view *gocui.View) { + self.gui.readLinesToFillView(view) +} + func (self *guiCommon) State() types.IStateAccessor { return self.gui.stateAccessor } diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 6f7dc7187..de3bdbe9b 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -37,13 +37,18 @@ func (gui *Gui) layout(g *gocui.Gui) error { if prevMainView != nil { prevMainHeight := prevMainView.Height() newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1 - heightDiff := newMainHeight - prevMainHeight - if heightDiff > 0 { + if newMainHeight > prevMainHeight { + // The main views have grown taller, so make sure enough lines are + // loaded to fill them. The views haven't been resized yet at this + // point, so we can't rely on their current height; compute the target + // total from the new height instead. (Reading past the actual content + // is harmless: ReadLines stops at the end of input.) + linesToRead := prevMainView.OriginY() + newMainHeight if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil { - manager.ReadLines(heightDiff) + manager.ReadLines(linesToRead) } if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil { - manager.ReadLines(heightDiff) + manager.ReadLines(linesToRead) } } } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 4ced8bd79..08ff53bb0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -58,6 +58,10 @@ type IGuiCommon interface { // return the view buffer manager for the given view, or nil if it doesn't have one GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager + // read enough lines into the given view's buffer to fill it at its current + // scroll position, plus some read-ahead for smooth scrolling + ReadLinesToFillView(view *gocui.View) + // returns true if command completed successfully RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error) RunSubprocessAndRefresh(*oscommands.CmdObj) error diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 26145c784..bc08013ed 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -82,7 +82,11 @@ type ViewBufferManager struct { } type LinesToRead struct { - // Total number of lines to read + // The total number of lines the task should have read once this request is + // satisfied. This is an absolute count from the start of the task, not a + // delta: the task keeps track of how many lines it has already read and only + // reads the shortfall, so a request for a total at or below what has already + // been read reads nothing. -1 means read all the way to the end. Total int // Number of lines after which we have read enough to fill the view, and can @@ -119,10 +123,14 @@ func NewViewBufferManager( } } -func (self *ViewBufferManager) ReadLines(n int) { +// ReadLines asks the task to ensure it has read at least totalLines lines in +// total. Because the count is absolute rather than a delta, repeated requests +// (e.g. as the user scrolls down, back up, and down again) don't re-read lines +// that have already been read: the task only ever reads the shortfall. +func (self *ViewBufferManager) ReadLines(totalLines int) { if self.readLines != nil { go utils.Safe(func() { - self.readLines <- LinesToRead{Total: n, InitialRefreshAfter: -1} + self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} }) } } @@ -283,6 +291,11 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } } + // The total number of lines we have read so far. Requests specify an + // absolute target total (see LinesToRead.Total), so we compare against + // this to work out how many more lines, if any, we still need to read. + linesRead := 0 + outer: for { if stopped() { @@ -297,7 +310,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix linesToRead.Then() } } - for i := 0; linesToRead.Total == -1 || i < linesToRead.Total; i++ { + for linesToRead.Total == -1 || linesRead < linesToRead.Total { if stopped() { callThen() break outer @@ -331,8 +344,9 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} + linesRead++ - if i+1 == linesToRead.InitialRefreshAfter { + if linesRead == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh // here to show what we have. Continue reading and refresh again at // the end to make sure the scrollbar has the right size. From 73d7b443ec2a534aa14ada8092900d0a3d941c64 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:15:45 +0200 Subject: [PATCH 04/94] Render content-only when a task reads more lines into a view Reading more lines into a lazy-loaded view (e.g. a diff being scrolled) never changes the window layout, and after the first screenful it doesn't even change the visible content - the new lines land below the viewport, so the only thing that changes on screen is the scrollbar thumb. Yet each read triggered a full render: a layout pass plus a redraw of every view. On a slow terminal that full-screen repaint on every read is a big part of why scrolling through a not-yet-fully-read diff stutters. Route the task's refresh through a content-only render instead. It skips the layout pass and only redraws the views whose content changed, leaving tcell's cell-level dirty tracking to emit just the cells that actually differ (in the steady state, the scrollbar column). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 7 ++++++- pkg/gui/view_helpers.go | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index dd7999107..acad4fb75 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -118,7 +118,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.Reset() }, func() { - gui.render() + // As the task reads more lines, the only thing that changes is the + // view's content (and its scrollbar); the window layout doesn't. So a + // content-only render is enough, and it's much cheaper than a full + // layout-and-redraw on every read - which matters a lot when reading + // a long diff, where reads happen repeatedly as the user scrolls. + gui.renderContentOnly() }, func() { // Need to check if the content of the view is well past the origin. diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 453ccd6c9..d139984fa 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -121,6 +121,14 @@ func (gui *Gui) render() { gui.c.OnUIThread(func() error { return nil }) } +// renderContentOnly triggers a re-render that skips the layout pass and only +// redraws the views whose content changed (relying on tcell's cell-level dirty +// tracking to emit just the cells that actually differ). Use it when only a +// view's content changed, not the window layout. +func (gui *Gui) renderContentOnly() { + gui.c.OnUIThreadContentOnly(func() error { return nil }) +} + // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. From 585c7f126d7fe48a9992f3a1d5ae928712186b9a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 22:29:31 +0200 Subject: [PATCH 05/94] Cache each line's wrapping so scrolling doesn't re-wrap the whole buffer refreshViewLinesIfNeeded re-wrapped every line of the buffer whenever the view was tainted. That's cheap for short content, but scrolling a long diff calls it constantly: adjustDownwardScrollAmount queries ViewLinesHeight on every scroll event, and each newly-read line taints the view, so every notch re-wrapped the entire buffer. Wrapping measures each cell's width (uniseg) and allocates per line, so once you'd scrolled far enough down the diff, scrolling turned sluggish - the cost grew with how much had been read. (A CPU profile of scrolling deep in a long diff put 77% of the time in lineWrap, reached almost entirely via ViewLinesHeight rather than draw.) Cache each line's wrapped result on the lineType, keyed by the width it was wrapped at, and only re-wrap lines that have actually changed since the last refresh. A firstDirtyLine index, updated in the same three places that set `tainted` (write, clearViewLines' callers, SetHighlight), marks the lowest line that might have changed; lines below it with a matching cached width reuse their cached wrapping. The cache lives on the line, so it's freed with the line when the view's content is replaced (e.g. selecting a different commit) - it doesn't accumulate across a session. The wrapping cost per scroll now scales with the number of lines just read, not with the total size of the buffer, so scrolling stays smooth no matter how far down you are. Co-Authored-By: Claude Opus 4.8 (1M context) --- cpu.out | Bin 0 -> 22717 bytes pkg/gocui/view.go | 120 ++++++++++++++++++++++++++++++---------------- 2 files changed, 79 insertions(+), 41 deletions(-) create mode 100644 cpu.out diff --git a/cpu.out b/cpu.out new file mode 100644 index 0000000000000000000000000000000000000000..24b4b40a40f2809d660809ebf24b5b82fa20ca33 GIT binary patch literal 22717 zcmV*WKv};ZiwFP!00004|Ga&7oK!{f_^4DjQP|%5k);k zJ;38U&)XYzAIr$>%s4YkxbVFtC{faqBuLIVM->16Yn>V`)#|*c3 ze<;20)vKFkjX#O@UyCQ~SKV2R zK>N;1dUCge5#IG4PO>X8!X4ny()7;vxU(3ALDy!9_mrP|P`s!7Hn>eo(>Fip&SDBI zXf<2BS9nLac&~53VDC&SDO@HfO1L@8Fjd z@!r8JK_#uSzEW{!aKe$$bO)h|>TqYVG${UTn1D~?dnJ4tuL4!H4E-C$oyBg0Ndu>g z_qXve^8IZ*6EZcfkH+^5PJSxiGv1wWlAX>N&w?zC>-F7P>~=UhYMl7|c77ILS7Ntw zCpa~izLvPNSUR-nCf}#?neu%)zXR^j?$iepcNVJz!}^RB-&f)%ZWF_)#P5Q;w7d0# zx4E-eWtcTwzOT$LRub>k`3O0BCw~wg z)ZBV6#O{K<1DE$f>@NO>!0zG?!9!YA{devxb_X0?HC}vu2j3>YzJosu4{O!*t;oAO zVNja|;{BccV|<^^?&QCMUun7?adUPT96j`Z4|It6x{ud@8d^=gH;VHwVBXKG#QR_HK~h|Q!M)(sYU+JZT~>ctnt_rK($sKEbA{scUsJ*khR?kx5IOj{`5Kft@o_Yd%= z;3@4%y#v12;J^im{};T!!f_fNgih_G5NLdtDo|?tX?R-G^qr_N+|X`}eE%RnD~I$T ze+Hh>H2sXj>9Y8he(!z1xc`?AX!k#eYTzL_wHdkOU~b+4=e81a^Jn2%Ek|#IomPcW zTds(4JjDCT_Yd)3!>_gH^gj6hVVHG5zOTwp$oEzG^YFa(oPHeNSA$tU&+UVf`!Js) z-#^S>fETpi=u=QKe+4JE<9n4=Xi{ztw)D=eV<&4khc__r8(l4RhSBFdU<$IkEkn2L{FTqP%Exk9s&xX@qebI(eoy_WuaKCRzFje+6FA>geZDN*;sFC*=D_`A_ow zqx@BPRjZ@_=y2X)vAohlRUfYQE4_O5Bac4j=F9`5+MsGx*<<{9G3LkkYw()(y8eWl zvmBV#TE6x0Ux~LK{yX@c_PYMCJB!tTC1VeZ`N-kN<%n|l8}NqqrhW`ZR1=mQ`Ch!Q z!7s=W)!=oZuJ)#W9_Pagm(F}A-q+-NW6W%YC%amG&(B#E%%5K|CTp`Mp~Z!m^+KT1e0c55MRH@x69sN%y$lCVtP@|q#QVs;U*ZK&pf%BZ;#9u^(@IW>rBj=K zB9~@u?gzj2p}q?ZZXGz#PQHJc&yxoEW!@B;Y9H#e@cpYWYtLEn{VV*cT>7u@00gu; z`uDfHv)F5Jse^o9hZoE5>+nJ-)avNF)7)9?bvS=nF3nf@nl!=tS9uVES{;27zW*I` z>bO;?hS&H8`Tc7=1R*V~cfE3*=i~SKw zK9@-Tz-LJ$f8cEiv=vCURdI5z@Zxr(GSY9WSHHo#e`xqecNTjOid!!eJvZdjkoi?u zL;f*=kBRmP(a{_H6I>fCy}dv3i#WO}?2o)1fp$c@TVI#%&SLMw^0>cvo^f1He}?-U%-kv~vqIDp;=H~1JIN44D{E7cU0Q`w}B+yX+-099@e}Q@XqD5@?iT=36aakAwMg#T;7d9H-xC?&SLq{sfPqe=WB^zmI0f$ zHAG$l6-s$cALR`yo5jr(Rvbn7&4;(P9K#Z zjrdv#(unsW&`W@Ie3)V}B0BZep@`!gLPTNMP853wE93*@DlX*12n-|IlX^cCu^5coCLx0S6A2OI!wC#Wh>sOF zXGPF|;o{z?5JTo1g!l*oBZxMV=uf#hYX*}xN`SDrw!(ZAfl)*oP4xTRoHd7Q+a)l?Xst%@rmL(ue^HFCIiEsciqJ;Cbvs!xflm7_2?=Y# zJIbkT!KV_Kijyy_L@NSaJ4+<*@!xXdcCyw4&c}Vc3#xe5hCrW9qr@_7$@`$QP=&SR z(+EsMwe=}_@@)xh?|_R)Wq;#^Vnl!A(+Nx`np^PtV*=Bzp#!I~zw_4wz~A`{0y7W* z9hOfB%<3uqzki5*YGw8hK9j&qqE*!&aywZ&0++r*3=Z~Bz8aZQh5eJyA~1_+)$~~WT_V)Mzj#Bzw}0_D1m+NJF42GQtZq%P z=E?cTKmYGv#iSMa;4itYNgL9Zd`vze?MQplf#4c;v5o|ar=uxPW5tAbkqWq&@OcF0 z5iL#cqPktI6M@rL=Jw?7G}emn6-ZSkw&oLDng~UffsHQ- zd?|qb;&!o53G5w%yTLTphVWj9DFa&z2rMAlLZauoovaIic|#D$!P*jzTlx%aEF!Q- zz`WpgvaSRUEtN1I6XAMhU}G_X#Y9^|^vB#T){Q{BwkrkMpAg;~JIln@QUXhfwv1q0 z#L2o7*nCxPNZJv8kFY@P2>*(}S43M*bZ|Ra4+0B*k}x%;Z(h@4vfhz&BAv;nqzmav zx{>ar2N90?^_X2k;OH1Mc4@3V;d?8KzS<*4VFlXtxs}~c)|0^bxM458ofEf<^&)U- zH?lU3bs%y#pNXxN1XdEXS5pzHH-XKgFZRZ1>PUERbV)O@wTi$hqOB%+*EF|_^&zl# zu7v7D_%R99iSRW9)(EJ{2-TNBo23$}GvQs(&}U+6ErGQH>eJiYF7_FLlSd`gr-ZM; zb(M*&bp+N4sO8M&%*t4rIcRN@YB3#i7Y^*1+o@g70Uc>EV{Ru2NF1_8Zg#TG^ zrYqqa32Y?VCZfOZcCi5jt}Wl$7suC)@Xm5RyAi&bz-FRtA$lhi!+`{r%t0sz>rRB$ z%)rK00$T;p!)_=0oItxF5~c^?_lw?o5WbDTHUV?D+rq&TDWM&4ob`sc0v^sj9G`EutA+UUx+)2D5Tv_Mimh@!5 zS3-J{UZgkaL;8}>NI%k_3?Ku^=VTBWOoos|m)XgN66n->uh3$>2!BM7-HY&D1a=W^ zH_@}*PBx6d-U+(}OmD*fDPVdNzK6gbqJ2&DpxecU6Igu`CnSyaA^cMuS|+x>A@Gfu zk#@+)5vYCTl=LOMn!wbT@Vx}~iYa;6?P4PdOd8QaFzGYG2Vz(=16%tD>?2w&y>BJA zi;W_%eK7`6(pW#jr=pZ(VC!1~-y+aN=612s1P-mk-qKiq!WZ2xy6aE)eggZ67SW(OgGBp|=-AtM0;6W{6)>M$ zis*B~zbEiL(GH;^aTmB#7UW#?sl>11eSawfkqPEUIL9I{3ilGA-Dw#&6lNpKg=3+AmT>J5ho~S5B z5q<^vl!2|&1Wps}b=*}r8JgX*G9NIS@IMKvM-zUAz!{>QC3=0glg%bjvU7}ZK*kWB zEnvnFevZI7qMau~Ps}0EZpJLp*;vA-fB+gx_yqzN(1Py1+wEj?2^@_lTgKfZl=?gZ zN57V)cO2nAN%0y-_(cL2@xy(%EanqvGf4uCC;WgM%6P(mCh#)??N^-l@FHH^{qzJi zJ#!|RMP`#ZWG=jquhKT2quL^hv%V zFzGl-R2rL(v&czyCbrs8XhXHORPT@6Sx%tSC4_RY8H7ubnnC!-6h5ZfCsfGY3IhGV zN0>A=lW-9^&%{|Zg+dnrbp@f;5jgO@gqlxeG&&PoT`6=GPzR~o z$<`B?H%Nwh>hb>RVsULCaI!BxOJiRU-VMi{AzBo=QSD!P*W2ArwvoWK!7_N$fbY!U z#97VKH}@|k%g9$`Iaxtgl2v3iSwq&6b!0u+KsJ(wZYSGBpi{{n;naLdcvwvKm*|^8 zcdGTEdcf^un+XgXA>EAb1b0c!Us?EnXsX~fLcg+FABY=)|=`j)a_*32yEYuP!6_;@Mi_FiwN&S zp%2yiQvFG{i)|;ccaL1%6A0f}nG>*#r%}{Bb;bh+s=(|}meVMhfSVs6j z3Ihex?{vG^UII%dAxs)h8irEcF1C-r(UF(LoPI_4NDQ20V(W7XpNnA+%5=Nfw*(Fh zl~BtGZ!Mvg6F!K-AOY1X)9qyY2`m{XUB(rJ*B5iLg7Co<22*qy>$#om0D)`k770;a zN%$)QW+mZ6C=8+6P^!P|cCv#6POg$LtB43+WME?$g<%5bX}61gN8tSD=LFrW37?6! zCIef;DGaAt9ep^On(ql*>MViQ5Pk%WNCvh>P#A$gAEOO9L||_TYV9<(mhi#S&s$6Q zND3nnXy9#b7duQ~Sa%6DfbdSYarCPxFW$BE_Vny;$lkcX?jzrl{p0{SNWLTAlSAZi zX+O`!ejqUYh?JlSgkM6@e~jPBi>GwYPp|re5c-?qI6~mq4`p#Uc=5MK?@q6JBsm;M z33MJFU-J9V;MqHcqX|p!rqmoGFuok7+j()Xv)Sp{$CAh7WXB2Y+k_J8K;gYpFxfPq z*~e31@(BW`Z2huLGC7?WFW#P$UiCzB3_3~RvaRjb;e6oJd7 zb*u9U{tI4wY|+OgJ^NJh{Bf}#2^?FC{7GZ$2%m*IBokYsD2$@oXsXY|CH)hDLH0WT z90`9sE~`J1pOPir$xahEXwS!8Vm_>iJ)O#YoFOp#$`oN=))T%3lRTN&8be`>u=y)+ z7krjL*NYNr1L0Ti682>S;bSR`6;Oxoa=X|$0(~Y(sEveg#YLTot#K5_38+E1s?QUc zH$pH&if(0I zJYc}H=~XW!C%Tsj4BjB+WD7=o1S7W)K9RyiAtx))@w-A`$yS6CtF1aG&L=tJ#fN)T zOMmn-xsvq!I-lX~6ka@U){2bu$1FcLx#2q5RRY6pZ{cZ9oH;y`7Y|$VYI?P+rW0@z zo;ley0!wWq&k7`FTCm!+WRVn87`{WYL;5Yw980HGE2h#Nxd|mM){4UDa@yUr6IEML z+&$bBM{5e5cOfBZY%AeiCan)#>|+X(%Z<(ynW`TrH@Z(KEVX5GB~tXHHCc~- zLX#Iew~Mu-u%jHVJ9u&1NxA8dwo46HdkP2bb+<{bJ5z|NwohhkPS$}!=l+Yt7Jj=G zSlmwd6be(QHkInQN$yDD&{`Rn-9dO~=|t}!d>Vym6w{EWacy>@FfN{v-i2GqyDUGj zN(b7JcA^c$=HQQRC+kdM@QnnwbFzZ_DTU6q(HMY6qoyVP`lrxFa&4=`iSuP^R-Z$g(Sw%ITeBKH zDHPkIM4jP$+CuH=nd&<4MPYt9|I*}q)n3W01sCg0p|kBil}Ldyap=916(}d`Lt#K! z={A>TwLUaSwcMA&_z}`!o=EsH+_pVpvB%w)F1VRoJK1LxPHjU)Dt5I`mNGG)B}+y> z3Ttc~vq9>ZO1${woF~$&_DhDZPS&5osV$O_WrRN@d@^%=W%s8^Z$zAI0ENl+>XMe% zoS$j~k{!`N3cEW^6OP?3!goq-wu|r?6lPFurU(hR*yj{>mq@7H@nFtw!e>#KC7@({KC`4|+(Y=XEU`D-L-=e8v#EBs{$&;>K`2a`D1p8vd<&MLWngO#g*gb+rwS%P zD70H@)i<}5X|16Yc8`pUz)vUw&sZXmlQxikP6yG!bO`N5hTdErTx=MHZnmDoG^O)N zi+zs{OHF$Wr?Ah~y+h?vQDh;HK0NuQGJ-27zzvQ&F6m9{0kCbrAT#!?tI>=B#wJVlI0|Pb;QAC%7E|w;3QHfC{A7-&aLOh| z`n2XcuQoneVkS^HW=kpN6%(AT38_e_lTD;B+m^0h3k_)Li;2k+G>O7!n;>)9nA~}E zQYr+w7<#%_WfpHQ;gh9+>?M32g?UulMD)q%Nll@!WN17ts&nGhEcv*5GMz%Nll^qw zZN&;Q?8u=w7~*78DRi@^Bqfd%S`JV7)6qaF}d=p0F7f~2C5zAlG*dfA?I7GPM5aC}@ z_=;-FsXoWycCy737Q~BS7o>CIyw?)9tS{)7bOBvR7tzIZgPF8gLg8>(hM6n9+LB}$ zwv@usqjG6YApElQm`trxb!l=ku#7@C+c)lpCgN#p>O9L*@{JQh0&-p7bk3dZD+>Fz zpg|B}7PoNN%sq4VS2Ssd&Bc~em|ku@SeAKtGQ4oI6%>w@ofMOu)m9`sDJv<=wkO35 zVLN&8*dy6-O<|$wn=d20hTyOjHeQ`Phn;K< zg?(kYV=}nfn&i0SWNRs0+BK#pcRSc&!ha)H++o63P*_1RkMD82*g6X9+szWo@(02v zVLl}TTPrE7#AP`FlP>EiOqwczju5^OHgak5<$ zHi$5Z7?{k*n7ql}mCC?&Q`lj1+1&V=-fY#~NpRWuxFuEDiRs~+IyZYLj4#It(+cT( zlI4VpeNAEhPMl&9lf^1F=hN2C-Sc%yHpJ~>-%yygUv6Sg68@9SHJ>DWJ%#lm26zf{ z&3h@#`d&hvB7B)N%clt6Kw*P`T7+hKABFxS5X!+S^R{Rp?Z8h5`VHMn_tB)I@!wMD zX)h_caWki>+PBFrsr?k%+B`RRKqeM_f08_R*0fBtZo9%ao$dn^dY0?j+`{PxlI+^; zVh1T4wx`<+r=`pMq32+#)BPQV)89$2??)^%73vpT6gHyQw*o!v?);$Y6_R^RNI=nIdT5N(jl(z==byx zJxtG%c&HIm(iFzqqwS0;*_NRnQXTCP3KvF8mu({9XV6i6#3I0bB$?RCQ3`vO$Y|;r z!Uy6(r%Y^Zp|C|nQ~PJQUF;ZzVPhrKM#6_;H1$O*QTFsvdh8Y>smCcC?j_NlC48tX zPdZEZRtj51=Y6p}=>&yN6D8C}!p~F^`@oYFPWHrG2-4U&Gwqgvt!)&xQ7v6xjNbhz z3fuchpz}lod$KdIwVlFt1nPrE@<$3?M@XOxgttfMJ_B1jDC|I>9SHOjg-c&bpo@g} z#5`pNwsun3i9l^JO?jHa@>K}rU@9LbLt$qqblN5>oqi^~JARmntz8s$QEfNXyW`QS zvlK=RL19x_E`L{?Y&b_@bNr;(S(#HkPht5Fcyuz2T_U`b^aw5yzK6mdsx`rLx-NEs z!l<1R=rZABMjOAMV|Vyr?1jI6Xnn zlU3v-Jw<<{Khe|l3_VNpE1je78|Uc-dXdJDg}d0#6xN@R3-Bu8vm`sO626zhUR;1P zv7+u0g-(ZLMcp;RgJN-ABYYo)eN_9F>VI;(*kuaqH%lMsDB)x8=fruZMN;LT=_PuZ z#vPq26xxkjCVaeN%KM{|&%oAx3j3*+q4!fU{YjxDeo;h6JWb_d*C-4tmejVQd@S-I z6I%x;9H81kDof9c6__`8t8g=0QyvxMwx;|$3g1!fd#Xp=PS#3+qeCZ%`fMkkg6rBo zkdt#@byly9Jr@-F55~dv$=gu>*ah$?&0rQ;O$=WMWauM%x zaIp52hs5cX_LQHXaDr;OsD$pIz_tAnqyrUI?p3gXD;Yr^z*8#SkKSSXR#WN?BFw>^M-uRglu^F=?QJ$O{ zpDJCHuF4ILnmAcE1v4oTb`13=X+icLk=|LOotedJhFIjY0WL zW8J8{vLFLn=P8`0np+olp_C{vuOkAPXQiCKv{u}m-IVT152ZvYebC6sdMa>q()`}s z?O@$4LF`WX1qv6ac99A@*-L?=I}j#~^`Lwn@-7owKU4Tw08POu>#ab$FTN0EW+jyO z!9|>jtxFUxQSCC-JD{i5M}Z}07YL}HlwXz#wI}6QC|sf1RjU7ts<^KL>yKR#RP~}< z9^dFi`85jHs8+1#4m{+d!1=aQL_u?JD#F>>nb>NjKr2OSt;lFxKLw`ko-7txAIckv zDeXgf8wJ`3pg&`tNP$Cx<_nm<)I4g`m-4m>v{kf^6&>6zHb8;?Gt3heR3s$vLQ8xl z1==H`jaZU1NP%%~ly^{|0|HG#puq}E+9ub?Kq?bT8QAKmKt}}HOcSRy;Yo?EHN%NMS;P`q&k>DcySd@oR3*6r0Nvq%Va{kQx%xJxu4Ln zLn#jktv8hNt_pNjv~G$XaJ$$v1=bJiCN%mm%4emE;QBDiyDQLL(RwKQq1!PRq(I3` z2{oMZk*FIpu~ni#iGXT_%5jDQqgHkixJFRxkm(4@dn(XVK=r_#SVha`6JYRk~k@AId z1QRL$T!GKUr}GeMkphS2NT^Aa&y!G-C?BN2AOSTOp%yDJX}W|OL-}w?+Y$v9*ep6K zA1+m(#3p4VK6J8W3Tz*YpF7xO%JHy%1~vvOFj(|~r=P!4V9Dms#0*cN{2NKg6v~Gv zFhtRYD*7%YWVr$-Hw+R`Qz`E)V@y*iAEv-CMH{Z@y)eeKLV*Q8pmlMuX_UVuBx)Mv zBNP~+V4}6I+r?HYFzF|0-KSH2L>A;vr+lOWBhgIlsEkp61$G~iKr<*`ErDiGK1zX6 z2s8(QRx5C5oCF#``3f`_Z(1v}_Ecq>GF_RW%v5G6vz0l@TxFgzU-?4$QdyuZROTs* zl*P&tMLt`qEK|NxmMbfimC7n*b=()oVrvvAE=G@4WessW7`9hnbKFKN48tX$yEY*C=+ z*tqfi1mop)5y_U+jPF(jifx)qV`)V{w%s9vAqh+zM-?dHx*}lp8{Ks$33<(sk(j1iPE)TYMV7~$f?PXqOFS0+$<%#Fv6j*p89v?`G$IhoMwcx=yPN|^(paN%ZIEsVG zj>5&hQ($3F+~B3LnUt@Td(@efk5*u`qTP*qR44mhf&Os|dmb&UeOJPF%J)k0_Uw=X zvu|jJ4kb_aVFeDC8@{QW^ux*H`9Xo{Wr`4O=i!G`Wc-K%-E4|X>tU`r{Ya7&IiKZb zQE?3{Z%Ik(JK0eMCJUV;DxKx3H>2*=jwU6Mn4D2yzP&NVD~uC3a!g5KV|-kJ!)K?6 zF#0UYXUIcgvvAJ>V-!48J{=E*olv094^jYUQ@#R2Gnv>LtH4+VPgRJ^VNNQrdl;rP z94uFO@J_n~__%UHIVs|;$sJvCu~Q06zM*D2mCCIDs6fwRImS7Zx5m(5Cbq^YFiwo| zDth5RDKKa~MwT4x9o_|<6#Ew3AC;dJ^Tt0HJFP(HF>wop_ns!WGpAFrP)>G6fy-s} zlNmItb|yJ#$YN&|xNsG#M$p~P5~kObsO+<;xYp+sm|d0_(}rii z1vTcH&pw}u3AmuZaC_C8CumJ_vM(e*|4w#Mfi-c>DE1z9eC%SfJC7{(vjS(XS!}Mu z@tmm{i+bc|WOH)+g_~VkasAWnmlT-x$;To}JeP930yF~~;}saMXcH8@24>F`=(HMP z(%8IsVrw4d6BU>!fX3iP?1}=1CTw=w{~h7i5JOSQ;OAA z>XI_IezaEMd_RfjOUl2(6Xv!M+yGDO>)>gl!ln6A6E2|qE2r43FQ9yy0@KhGk8rwO ztgQ;?=SrZ3ln;0y7nDmZJX-kLjs!bbwTEODOM&!RbtF%~oKxP;c!q1lC@KHbW)UEW(E% zBY$a`zB^^%LL0TM`my?n+D>h+ULXyfPK(jE*Q;-xhjXzGDjY6-GBQE^bx>1qkesZe z3Vk=khxZ02&KIpdpYCYUk>VL&C+nm_x3VVIR2pbirc4sx| z4ULn1s=~ptTGdpD)jmy*8W-!LLQi||VeVwiW#H+ernr;os=}al@_LV@l=pl{2=P+N z=O{2o(bDuz4>|9%Xe?7^*i{u}hTT-?W(&a}8BaIqd9+(!8U`QE)}=#o-BiU zsL;*!SqGx|wv!h<)FhJ^PFAAAmK&L`lH{k|#d@kx{IjeASw{ImYFEwdzskaLAhsxdCM8fyr^0trQ^;VNezx7e!uzet? z6>`u{k@Qi=q>>`(tHS7Vw%hcxv-_$kq<}tCVUO(v?nObfZ(H~*6-@n9*kMb^P#j-P zOUU(pNlJ+GSxd@mv`+mTYkw7Hmpk}ru9~X-)g+Q4E;c}g+2ve+i?svP6kPv-Dh%Ew z!-QLjEZnz8H&9JEkM+3WigLWN zJ_8%`6qqM6=XgigU=?#BVJIerhO02{8$4W@##T|jUnaCxQNB=tg<`9<5wF)9p+cvgm@9X%-}AG# zbCU4;5yRBs>Ik*LyLdmjlZ{lNZ~UnHPK4x^$lW&5gmSS_DxA6D;uw|u;xG+c4J_hJ zX=ChSqgCizZf|bxh4j&Ck{aB_#;7p3jA^pVEyt)SQZ-hEQ#Lld2O`1S8JmisbFpzM z4Br`FaAl16xKxe!colkiHNYP%`yQ9xO zQH9kT=ZJ8@8p^vqFFc1elrL6bF?tRs@Up*2Dh%2pf!0z!T>`D8e2D@}5NPW2c)U=B zVc*GGzjc(4|E0jRj`F1nEJdI*_hS}Wh2?7`&{4t{-^Ypb9!q-p1a+c1Nu8`tQR78{ zQ&s4D0L^I{J4tv~2Pe*_EcnN!s-jpsxosMzmQ@&R>oYURVhVWnw50W!+r_4&gU!`@6mD<#HBWI@;1;?o4AAyu*~_oRw3bKt{hTKXq5Tx^~S?Z?j+3uz#dU`a56&9(mef=U~j8_s79r0KkczB*VUv2n>`lY%+U8pWnZ+LgxViiu=7QhVp zn+r}~oTLRvQ1%^CRrX6%XuDo2=ygO!Fzt1^B$XAvRE5ho^z@b{vBVP64yemh*j2mO zG8HD5Q(UGc&0dzuFuqb@jjd0+p+2=Uw_izpnp`|Li!E1SL%b-UsfY`ju5b2oHR%EX z7h9o1ds~^BaX|~yisY54i>*{)bh+s^wQ2UsRQR+?g=6I;*Tkf+Qj_*wR;#eVbP=~v z-cD{Bw^6=UfwjU#`~>S^*Ql_!m$Y&-Dc^dVs19GN!mPpg(82!5Ps_st>r^;BJiaOX z0XK!VJg-*QsB6{tGu9=uIb5$o-!jgI?NqH#)pgmR!iG_Cy2NE6_GWd%P3cLfp0+do zH`4JNRhZmg>i9tff9oM(f3Z_4NL+=Ap0~b-P7{*&kz} zO&aS;`6Mh~a%!Px2kx*%rTYyu(4HvtqR@&f1lgQ_Np-I^SDU8%}Ihleyx6^?)^7nWnX72PjX*> zW}R%G3f&GY5rN-tD2Cv(GqABufo+PmUDlU>tHMcJJNH5D?6sIueV_WR)Xz8dt88(z zE_}K!Q3)#|@o?&OVcjmaUxh>Ky7l7jG`5%WZ_u~M#MTZ4b|~6TMgJO;ZU38~yi`7Q-^DcWvDe-Td$s&H*B{dC3GUp=56RKHWdS6?mtTMdU) zC@$@E+bla|I^EZ0gYyMzI-bD$$I9MWd9#_p)cGq5!!NCDl9-oi*SVSIxC*D1N@H=r3^!H5 z);9`#qiFxoKXSX+2^C6KNSK3^e~H>E16z9)*o!c8(CI#@!l19^*gp{70;bJX7&iH& zP;=i={u80F;_UN}Doi>lfi_USC9Tv2;m6ez>PZ!UDC``4KjW18<8`(# zKdI3ExQu1ZC47S-OvPyx7VMM{zo)zeY0kvfw+ei#;HC0Ckiau4wEI><9isA=!ZNY7 zUxEDssx_L5vnsS3EysVD@^)CUoq??b3LHR#dJ2z)ol{|ZD+%-i zRAJh5WOo`nM)|S~p{I$FGq8o#Hz+Af zk?EIJxHeBR{R9<`e0CrYXBRDq)csvAuxoj9&uQZK7l)T`6!{9ljH^QKTB<-YbODylTHGqH7Cf#YId+Zicr<$y^CB-Cm1lv5_QPAG6f zKy{?f`>mbj9SP@kuBpY2Rt|akueAf_m%CJf$n&vPTRV~p*0*uMiW@q*Z5%h9cl(&& z$#@0j^(~F<4VAu__4?5EVWskc+UtISNo@xlETgb(^4dDAJ!~vV*8MHH)L9+u7A3HvAl(w?h3rg9>+^Pt? z5k>7BFnpKXw@e`XD>OEC;H+J8BGKLf3+*7VdDVrvRrIuXq>{-^ICg|vuv8-33CU4= z>TYVYJ2;@Pea5d$lCi^0#TifgI^ayXaJo7AM>{%_4|61>s*q`^j2(Xk&jF|GNia)! z&9z;llOx5{my>mNz!uvhdcIT&yR##yiUSw>)B*cO#p}xOM#cnR?^8z#3D_*w#R2o< zwMsQgxq4k3DFj=(I-s*n(|OE!+5TtORA_RsZVp(uMw-vFly8yl!CA^rDR4?;wzi<9 z>h6G&^%81_c~b8#Yig==b98sucQdwwiGkyGgTPC#Y!yfvc7dpdeK8uoVdaSWn;9nurMsj;z>zgQZd;)?pr0T;^f*A5VU z=12vbGshZ6HEVZuQ_a-R0ULTv6;pDN@@cBD6&EQ#tH4=BJE!Q!(7);LfCc*`)X(zF zT6PAu&MR#OVTq%{}`drR;a+@CfVMGUv-yj3t<}Vo}Ebl>%e9_8a2VB14 zT{eT0u_r?uFlnTWC|#m_sq{rJQGQW@i;DKMqJM`vWhm+t>04elD-AQTbxDCs!na(9 zrG~>CFl)Ai>O}arxGA#lLU?a27SIoI40Q~1BzlR%9WdCgPuYzM(hji>cO+ZslJJpm zT9VopxLs_71J;yr%Ivbn5y?x{NCzw}Hw@D*XOB#J7+6W*fYBr3R_qWm#I_itQn46W zY_tQW$F0~SoFurBqaA-rCUl*U{*6~?e38Vjd^qiUXMR%Gl67P~!Fq~>X%a^&lEY;? zq@qr^RM_X=JEYb{o;Pwx$2j1yeU;NN{Mj?RfO3rERxfE9>wqnGg**P3cfuTwO<@l2 zbuq$=i--2)P+WX;0M{=o&k*nb^WGNq6@~*w)a%F#6$N8nB`;j$trYYHLs26y6wHr$ zt3--|F@K@qY3^%kguTDc358=hO+tl6qlnQwr?wFY_#!z4p`4l??^B*9asvKFMhhb^ zCmPAii4+B6{z4-s90}!l3PN62L_Y8ogra#(jQrON!vU|$!e0;yM+~Dd9Lp{Aa_eEx zXdaFE@|t>|yM=zmm!5)XF;srNUxfX^2#%=JYIpL)U*^?Lomm=Ov30y!q` z9A7L{=+E=$+3yCUzQ)EoApud{Q$H4p7;l9dzT|x)g+9f{<UqZ>P8mtqAGVR)6N=^-EesUx+J*Tj(D|XF z*sY*NEaHDi+xrFnSd*egp1e?D&WA=cYUCF+ZtOP#`8fgK-&z*#ib1vz=4!a`p# zKbjMY=7kCieZl;wN6*eH%zwMlhxLkrK_gP#Q?DpkE13UIB$Q`Fqi_0yM${Y1c{gfA zqB+r6)13OTra3Pg&2nBU@(1#xIRT%t_JBbwtc3`auEjK`vRkx(G;So0=^ z5qK=%E4UE_IeCToxuHfM=0=K;0s$8o=;s_w{gg)I(X$KuvD!BqMU$M0A4P#bmRp|u zcT*xir_^M~VN~~AXBz$+1F-wHrUCiiB((tuE|{X_(X;Crg`t@7W(Zekbx(m2lMh~s z_=0&&jOa@(<==1l3Jq`gzcnc)IXMwQOm09>gJVl^rTp8my=sju>S(t5640gbK37M16Dugi^Rk`Zy9dBYQ(%5jSFLF$KHtu zcP1t7HZF|igz-D1xDq0&FaB)ot(TO5#FtZ<<$%MEFLZybe#{puih6?;C(hUHSej*f zNjI#pF}!M+W59t$y}3ytE-3Qn`xyC+l#&J4dymhh! z4{Lf|(U#FXUm)O#_?nx|p!dZT)UD~L=R{jZbMt+X=KdfO#!Xxv^WUI1(`;E!yf3HF ziTSPdZ$d|Lv=PIXA25P;&y7QormwUx^1Q#jg^tj{&b6HXJIwB-k~ZCAZ)Pa!iK5oF z9;C9fHno3lI24r|48$gEfQ9YJ6sR__A>;3`+B&+ExX18CVrZzukgTWmih|xhB_%s? zC2V_(*V{Ir@9=g}EL;?eqP(Z5m6OKpeLE$hZ?HQ^AlCc)ln|DtG{Ij_L|MfW7h(ES z3Rak3=KRfUn1XHwOA10ZZsBftUrIqpOqABYap}HaxD*&635Nm!kG~)oiui*Cue|nIVO%m~K3zfS%8x;}sK%}^-peLT5uZRY_M zbc2eK!*J6AbQkbHWcI7KKuWD({%gKqe!vjhL(_Ap^&cZjk}bY)*rR9H3Wr5xq$1hk z3y1MPWXq#TjRr}%Xlz6pgfKi^KhGERzLtWSFwx``7UUKBB29A(PyxjZUxDyooaSp! zG|v}&TZVBnt!D*!FGqa-p!bDa?+(Z1E`zVGFVa+c>n|Jm{ybmI$oKxjhIy&L1`yKk z^*(!R1Yqa!L`}41G-edaG|FAq@6D#7&?w9cL`3vEY$hk7-deXpg+OGw6jASeR>xKt zT}0a73L*EscM9Wdx}?=TVSj!@pFft8IWqmxxQ&xBlQ_!eh_B2u(TB_Y&~jPD!}~2_ z^=y$@sqXOy{jt{!U-%_|EP88F(a0Yw^a&kMDouiG4D&Drkx=te{l?#jGzcyLJMmEH zYidLT{yZZkC&nC9j`$KKghylOf{8Fy{cyk^OKD{0$JW1yFvEQ25!l}H{xvE675DuM zFw|{ERdGH-g@yi@75gz-#3H`Dn0$!Qx;Op7|3PpU>G8)BB0(Qi;Fw-D%<-7PT~AH# zoBsiN3|C41NPOOk@-fl2BO@@IQ)-sPQbYU3lG;HPm& zZ>3OFkb_SINrE;lft}HC4>_!=9Lkmv1a9SQFBSByz6?@}jpmxCegkoTb*5QDLi zm;DjUx^O^6)XF` z>l$lQ!XF4l%Sib91tCwgi7#w;{K41<-uuhg+uEVRM*g6YFQYo%_fngVWFtBlEc(NL z2X%@PbkkUo_nrTM&g+JXyeS5sN||8{+P>BNdmUNT1PP}74%0*OM2vvpiyHNd@;Or* zuhet9k2gbkO}$P*Uc6uN?3omH0{FSz)gAaJrtTY>;}H0MohrUa^EmN$n+nQf&Ptt7 zq^UrfN(GgPBL~CySVel`x|EVpnlX9I1~EaWIB)uq5MXb}TDu#E^*E z=Jy{|AV-TNKg|TEFuX4(rKKf#1x0>zL_@*qIKV_A->+CA3yS=?sB=Ris**|w?(J~Q z9|}h6`oeV!L>f7mZ$!+86&Y4SR)%C>x#o5_IqM`jX;M_0OflmvV)na~MlO=55i#EK zh@MJemIb1KuVttxR-wze$e$yz;&M(UL0s1t^c5J9Iz_=e&zq92*ODENs5UTK#7v#g zAma1Kg!Bp?1!La4ij`a}ZZKNJgxboDVQ)Cvq(#H}-lJ!~>5obxTf|JNWlXuAnBt1s zmY{!*Gh@*m@JD00<{viwDaWqEp=iu3K6p2r?~55JiE@ITd8Ztr+?1w1*+LUkW~S>O zs@M)f!FOM8&;+-X?*;=#G+NIHhiW(R1q+OPv%@5-5^M`$qckxjxg4+*!b`!#c5okoWn7nJe{OzCRic_*ypi=f|3OO#SMSq`ztn$NPW(lOZIMlEBM? zkoU0!>I=#wUNMSj1Oir`Gh7s_sB+Nq9535<4kmw7BoIp)Z!(@nxG@nqO<4h1K^vtc z#$#tvQ%R1B960)QMj+tLtr%Gm+80-)%&_O?NBqqUk%dmhK3eW>Ucm50>J?!bdMX36 zJ{Jk?Bpn5$+0rE9yO$D#_r~F;Qr|VkG_z@j-wp;^+NqUrOz2CrL;1N;e?ia}NJw)T zg}&y#rbbaXrTC_~JIx6tmR-ejXTea+_z#fcRyG$2!)20Tb|tHw3hjeTXaib$@3dYQ zgv0|=g(sQv7K{)0gGR%MFYJ9IC9b25i&s|SADDTFzTWsP!^k)CQ=CHlzPbnhFR;93 z_zRlEk}Q!;a5y}A_DjB~A;h3Xth%Q`DCP@@zk)Fnr%{1x%`87f4-NGXN*Mlu)0SvF z{*46NR@iiAWi|Uf)~uG2T?}2?n`{)2XuvSSwn(7hcvC7%GW#(_F6<9B@(c3sFkc9i zSYB1n7i@aVm?OSm)7((w#>kk;iJ0xAww_(s#PEfyd&D86`e9%2mXde^33e0DAD2d4 z*B31E1rqYTMhwsDSb!|Bm>+7~*q&KEyC4svd(}OSd;woD z&#=>+W}4m3dcU3@FuaRt!t#vz3L6=boIuD^C*X_K^@Z(ta(0nF7<=Z?czih;ixlO> zw7)0jO8$*R3YE~*Xo=~HR6cn_e)3!@-$Y{(P3A^Z`m8XNpGq%vL;3#3ej}p&^8@dP z6|?%wy9MzHvGd3e6b1cxp?o8!DAxEH&&vk3(Vo5@l)hq01}71hL|70irR5|3W+8-% z8U>y@{zx=txuUfLMbVfMiT4`%Z?PbOg19S+RJs%7HUY-;m>Z%O?G5}dh>{L_LC7|m zL8CeDTuiO|+XO;ywn8j7Cu+oMH}PQ#-iX}HM~xTzy&DvoI`es7Q7j}5!(h$JZ%U;z z6FC(r3L276b#H-0U;c+h(b&tO=E3H^NPc~BIIvcsI46B8exXEw=#t$i+Gf&Zn;C&f zz~oDATqY)S8;;nFNX(z-3rJM8<7Yvto8b-lToBv5Vnj?h*`?{oX=YN9D^FhLV#Gtz zh|>09g^%r3BUTr;(Qg?vJN?@^lS!^kK{36B|3cg{qOi`l5 zhiDoDhKLw=^#ooeu7GeP6gDEUmX=bBdOxa|=`63w6S~bYyYT$0uXf&@CF@nCj zzLw@vj#rfhjpk^#ML|>?KJJ6~gZ`NCZEoP6n2IP~ju!t=ctxf3O9Gj3?!4lQ#%e|V zzMxbViMy}sf!8;Dk-R3h=kWgHABbf112>rj+XpQ$Vz1V&>x(u`DPv`NfZ`GWTp)2i z;jI8xb)7iNZu_Cgay*_c7#mMCMPp4H73DQGVz=fjh9e>2lg7Q>!gnH}#<_JRx8qL{ z%pWl1)A)wV%7VS=ixe2%hY}66C%>GruaBw8R}vWeZw!ps6cpImk%VRbb~qNboytOA zelvg6NZ?1y=(|xP|Lyv>bcm?XC^U~e;H<=}K24vX6t6$9dk~xQ zy0-spUZGLfdn+ZD5?j3LD84HohWL5y3Yb8bXZ%9@&3 zv9vbq%afs(xU6DOpv-=)@Eu>Isl9!R#zNsjn^%qf!Tfqf!Jw~Ez_3{(t{f=}7%`z6|Z=R@6-wlePNr8 z(UwuM65p<$K$Q8+ULB=MMC}zGS7=c~D8i_%>qI3ko`go-y3 zI?iQ0ax9+PedZhaMR~FM%?%@5J7VPfW4Ah|QAB~_oGDIuh%|S7QEz&Oe4xjVj$j7d z3uT|iPgKW)I!z4>l-nyIXvFXwP)f)1%!$r1zs+s>QISz36l-S^FJ!QQBPeS?Mf&&j$4BCjep7B@faF2@u7-<$=1_J4F1+-p~EHqHyi0$4vKPf%l}5*|1f z7y86Ki$bHYFw`uyqnf6cLU#ziji1#>?euG65oXjE^vC>BLzJQWf>u?hGh!4NEyC#A zz9g@3Fj6y3;`5wFzPzS*2tb4&@0Aa+qD;KS@S^$pI`8sB$g-lCk&;r9UC1NNSobXd zPUy0ZKbS9LTzDseFP7INHUC^tZgzt`JeSl0kvF1X-x#~huZ#T}QSW;dvr)u-zvv-> zuD>KjS6tyV2}NV#IDipp=Fc-c_2s`W8;x;a=nn{^F!EM3ns- z*_~W4=7z)4vtN?~HOD7Pr|^CcIo#KSjYI$8`GwN>tCvMyLAhU?@%SJUb@}ypmYzdC?%nrc*xmP#~+9pk&4U$lnHQIbjmxO zwEx7(NH)@c0}9NwgF=*C4j?{*Wdz?^eTeBlq{&wlYtkUp)Cii%3`{g6ry6cFO1#Re zG2U2nBYF5QT_q!B?wz7Qpq}wjkr9pkZ`=oOjfCx9{M@OzM~x@d@QnzbRq*XL<~=>u zEos8Z^?GW0f0wY3_R>L znmz9EM{}bXCa9>xF;*xuu_sTn$3>6fh`*UHW{6WkctvShT9B$-qtG9V8IfFH&=+X= zHzOAvD_^9dk5H8*&+Jtk&V8)ZTgj&Dk|wv!Rc{84xV`S{KK*}8P%)PF zMpGa=L))s~oS6D~K4kmD{~l8A_nO@dtk4&UHt_|#kK7bnQC_T>ko}t8YX2U%&=(H- zgE#M6MiiP|cbXC=Ky2%p-HbDZzOY5!O(ql}Z;nl{<>gSG_ulIawyu0#QNcTv z67L)?5SfeW9*la0?F)9}VNw~}6oFMqc08kmq1jrPZF;+LlGn4|*sG`;$~P>R0%fKs zYD6ol^b^JA20e=~lX*-8<^7NU;glo}<5eS8D=+46X5horkH`3fc(a`kcVG2m5pSc4 zTSCZ-1njwCU(gV%{n_M3FA8zY9Bq9ghLt19)^;a;iuIIrBECZ7mHYxDYWrDuQKCPJ zXNp7wy{27$ROXFo6XN%0mw9bRb@lAV{uTn6S;du7flEB=XoE2Z8=#?SL;DEfqtscTT1DOz(dnjjA2@lz)Ga>oNG^!%!kE2{76yoqk@R9k)6)LC()SU3)qjupO~C0l@2dkW?rVm9j|f4 zJ-ihfzYci3ItN#bD9eu1WtIumw1v%l;(a}-wH)(Bo8l%P>sw{fy?F?!x=0C$lGlp7 zidgg~|BtJvc2Y!2yDc^{SV=61Y7na94+N0xBu6TdB;>9!}M55*J5R<^x?KbTN|fY)ZmFjZlThd&rIA0>GZrmV8WlTvj;A*|<0WL#e2@s-45 z7ar>fizvFVvl5bu54?Y?7?Wc8kRmE1ZkxX$MRuWE;z^zxf}@eKj;)j zv%dWN)PlM4$1-xFv7$yo8h=?33iyHro=~J9rv)A~#;U~@9)Bn=7I-HX@&3JH`E|X{ za&CCTk8y=uf7`h!vb-wbc;e?X&PNN!OO%NgY5F;65+epv;EHyI}0r6}}& zeWdlVfAdx14TKRx3@v^+mo8GDuLtpXfK}2YZ+yB=Z2<91;kaaD{ zLk(|s7r!LSu9=QSTE1$;-fr}vt)$JW>9=e&1$nii=;PGF!l9Zrno`e&d*iirQmL1A zeYz|nkoe3hf_N@GuEsE3WOJdEX%w{s(q*ac5r5c}i$Ad}C7!lwR3M(+(lK>;?`DGp z?%z0gB@6|YQTmOthUqP%dF{gd2EJ%h!J(R-r#v;2JdlwRR$J}o`S(Z&M9Xkv`LK+m5zX_3jo0zFg2E9ahGDa? zFA_E04jL7iUlZNs$j*?+7m^ZU@s%mW)g@i82k}f}1zpD&@cpf2fj{>D0{{U3|6k=y Ir#|%n09n;AKL7v# literal 0 HcmV?d00001 diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index d93f84954..e4c5a5f98 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -50,6 +50,14 @@ type View struct { // tained is true if the viewLines must be updated tainted bool + // firstDirtyLine is the index of the lowest line in `lines` that has been + // written to or highlighted since viewLines was last refreshed, and whose + // cached wrapping (lineType.wrappedCells) may therefore be stale. Lines + // below it are unchanged and can reuse their cached wrapping instead of + // being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while + // scrolling appends new lines to a long buffer. + firstDirtyLine int + // the last position that the mouse was hovering over; nil if the mouse is outside of // this view, or not hovering over a cell lastHoverPosition *pos @@ -457,6 +465,16 @@ type viewLine struct { type lineType struct { cells cells trailingFillAttributes *trailingFillAttributes + + // wrappedCells caches the result of wrapping `cells` to `wrappedColumns` + // columns, so that unchanged lines don't have to be re-wrapped on every + // refreshViewLinesIfNeeded (which runs on every scroll event, via + // ViewLinesHeight). Wrapping measures every cell's width and allocates, so + // for a long buffer that dominates the cost of scrolling. The cache is used + // only for lines below View.firstDirtyLine whose wrappedColumns still + // matches the current width; nil means nothing is cached yet. + wrappedCells [][]cell + wrappedColumns int } // trailingFillAttributes describes the fg/bg colors that draw() should @@ -815,6 +833,9 @@ func (v *View) Write(p []byte) (n int, err error) { func (v *View) write(p []byte) { v.tainted = true + // write only ever touches lines from v.wy onwards, so any cached wrapping + // below that stays valid. + v.firstDirtyLine = min(v.firstDirtyLine, v.wy) v.clearHover() // Fill with empty cells, if writing outside current view buffer @@ -1358,48 +1379,64 @@ func (v *View) draw() { } func (v *View) refreshViewLinesIfNeeded() { - if v.tainted { - maxX := v.InnerWidth() - lineIdx := 0 - lines := v.lines - for i, line := range lines { - wrap := 0 - if v.Wrap { - wrap = maxX - } - - ls := lineWrap(line.cells, wrap) - for j := range ls { - // Per-segment trailing fill. When the source line opted in - // via '\x1b[K', the LAST wrapped segment uses those colors - // directly; earlier segments use the colors of their own - // last cell, so the trailing area matches the bg active - // where that segment ended rather than bleeding the - // '\x1b[K' bg back across color changes in the line. - var attrs *trailingFillAttributes - if line.trailingFillAttributes != nil { - if j == len(ls)-1 { - attrs = line.trailingFillAttributes - } else if len(ls[j]) > 0 { - last := ls[j][len(ls[j])-1] - attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} - } - } - vline := viewLine{ - linesX: j, linesY: i, line: ls[j], - trailingFillAttributes: attrs, - } - - if lineIdx > len(v.viewLines)-1 { - v.viewLines = append(v.viewLines, vline) - } else { - v.viewLines[lineIdx] = vline - } - lineIdx++ - } - } - v.tainted = false + if !v.tainted { + return } + + maxX := v.InnerWidth() + wrap := 0 + if v.Wrap { + wrap = maxX + } + + lineIdx := 0 + lines := v.lines + for i := range lines { + line := &lines[i] + + // Reuse the previously wrapped result for lines that haven't changed + // since the last refresh (i.e. below firstDirtyLine) and were wrapped at + // the current width. Wrapping is expensive and this loop runs on every + // scroll event, so only the lines that were actually just read (or + // re-highlighted) should be wrapped afresh. + if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine { + line.wrappedCells = lineWrap(line.cells, wrap) + line.wrappedColumns = wrap + } + ls := line.wrappedCells + + for j := range ls { + // Per-segment trailing fill. When the source line opted in + // via '\x1b[K', the LAST wrapped segment uses those colors + // directly; earlier segments use the colors of their own + // last cell, so the trailing area matches the bg active + // where that segment ended rather than bleeding the + // '\x1b[K' bg back across color changes in the line. + var attrs *trailingFillAttributes + if line.trailingFillAttributes != nil { + if j == len(ls)-1 { + attrs = line.trailingFillAttributes + } else if len(ls[j]) > 0 { + last := ls[j][len(ls[j])-1] + attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} + } + } + vline := viewLine{ + linesX: j, linesY: i, line: ls[j], + trailingFillAttributes: attrs, + } + + if lineIdx > len(v.viewLines)-1 { + v.viewLines = append(v.viewLines, vline) + } else { + v.viewLines[lineIdx] = vline + } + lineIdx++ + } + } + + v.firstDirtyLine = len(lines) + v.tainted = false } // if autoscroll is enabled but we only have a single row of cells shown to the @@ -1599,6 +1636,7 @@ func (v *View) SetHighlight(y int, on bool) { cells = append(cells, c) } v.tainted = true + v.firstDirtyLine = min(v.firstDirtyLine, y) v.lines[y].cells = cells v.clearHover() } From d181615c315b682a2c26c89ef70ca141a0e66cc2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 11:46:19 +0200 Subject: [PATCH 06/94] Make integration tests using commits more robust Some tests assert that a specific commit subject does or doesn't occur in the main view; interactive_rebase/outside_rebase_range_select.go is an example for this, it asserts `t.Views().Main().Content( DoesNotContain("commit 06"))`. The problem with this kind of assertion and our test commit naming scheme is that the diff view begins with a "commit " line, and when that hash happens to start with "06" the assertion matched it and failed spuriously. This was usually masked by our MaxAttempts=2 that we currently use for integration tests (it's quite unlikely that the commit gets a hash beginning with "06" twice in a row). However, we want to get to a state where we can set MaxAttempts to 1, so make this more robust by changing our naming scheme. --- pkg/integration/components/shell.go | 2 +- pkg/integration/tests/bisect/basic.go | 16 +- pkg/integration/tests/bisect/choose_terms.go | 32 +-- .../tests/bisect/from_other_branch.go | 10 +- pkg/integration/tests/bisect/skip.go | 48 ++--- .../select_commits_of_current_branch.go | 26 +-- .../tests/commit/create_amend_commit.go | 24 +-- .../tests/custom_commands/selected_commit.go | 20 +- .../custom_commands/selected_commit_range.go | 10 +- .../tests/filter_by_author/select_author.go | 20 +- .../tests/filter_by_author/shared.go | 2 +- .../tests/filter_by_author/type_author.go | 8 +- .../interactive_rebase/amend_first_commit.go | 10 +- .../interactive_rebase/amend_fixup_commit.go | 26 +-- .../amend_head_commit_during_rebase.go | 20 +- .../amend_non_head_commit_during_rebase.go | 16 +- .../delete_update_ref_todo.go | 38 ++-- .../dont_show_branch_heads_for_todo_items.go | 38 ++-- ...commit_in_copied_branch_with_update_ref.go | 18 +- .../drop_todo_commit_with_update_ref.go | 46 ++-- .../drop_with_custom_comment_char.go | 6 +- .../interactive_rebase/edit_and_auto_amend.go | 20 +- .../interactive_rebase/edit_first_commit.go | 14 +- .../edit_last_commit_of_stacked_branch.go | 32 +-- .../edit_non_todo_commit_during_rebase.go | 10 +- ...nge_select_down_to_merge_outside_rebase.go | 8 +- .../interactive_rebase/fixup_first_commit.go | 10 +- .../interactive_rebase_of_copied_branch.go | 14 +- ...e_rebase_with_conflict_for_edit_command.go | 12 +- .../mid_rebase_range_select.go | 200 +++++++++--------- .../tests/interactive_rebase/move.go | 72 +++---- ...e_across_branch_boundary_outside_rebase.go | 22 +- .../interactive_rebase/move_in_rebase.go | 82 +++---- .../move_update_ref_todo.go | 38 ++-- .../move_with_custom_comment_char.go | 12 +- .../outside_rebase_range_select.go | 102 ++++----- .../quick_start_keep_selection.go | 30 +-- .../quick_start_keep_selection_range.go | 30 +-- ...vert_during_rebase_when_stopped_on_edit.go | 30 +-- .../reword_commit_with_editor_and_fail.go | 14 +- .../interactive_rebase/reword_first_commit.go | 10 +- .../interactive_rebase/reword_last_commit.go | 8 +- .../reword_last_commit_of_stacked_branch.go | 22 +- .../reword_you_are_here_commit.go | 20 +- .../reword_you_are_here_commit_with_editor.go | 18 +- .../interactive_rebase/show_exec_todos.go | 18 +- .../squash_down_first_commit.go | 10 +- .../squash_down_second_commit.go | 14 +- .../interactive_rebase/squash_fixups_above.go | 22 +- .../squash_fixups_above_first_commit.go | 12 +- .../squash_fixups_in_current_branch.go | 12 +- .../view_files_of_todo_entries.go | 6 +- ...commit_in_last_commit_of_stacked_branch.go | 28 +-- pkg/integration/tests/ui/accordion.go | 30 +-- .../mode_specific_keybinding_suggestions.go | 4 +- 55 files changed, 711 insertions(+), 711 deletions(-) diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index 70b12146a..72cc3d95c 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -256,7 +256,7 @@ func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell { fmt.Sprintf("file%02d.txt", i), fmt.Sprintf("file%02d content", i), ). - Commit(fmt.Sprintf("commit %02d", i)) + Commit(fmt.Sprintf("commit-%02d", i)) } return self diff --git a/pkg/integration/tests/bisect/basic.go b/pkg/integration/tests/bisect/basic.go index dbce50969..fda3c11a0 100644 --- a/pkg/integration/tests/bisect/basic.go +++ b/pkg/integration/tests/bisect/basic.go @@ -34,29 +34,29 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). - SelectedLine(Contains("CI commit 10")). - NavigateToLine(Contains("CI commit 09")). + SelectedLine(Contains("CI commit-10")). + NavigateToLine(Contains("CI commit-09")). Tap(func() { markCommitAsBad() t.Views().Information().Content(Contains("Bisecting")) }). SelectedLine(Contains("<-- bad")). - NavigateToLine(Contains("CI commit 02")). + NavigateToLine(Contains("CI commit-02")). Tap(markCommitAsGood). - TopLines(Contains("CI commit 10")). + TopLines(Contains("CI commit-10")). // lazygit will land us in the commit between our good and bad commits. - SelectedLine(Contains("CI commit 05").Contains("<-- current")). + SelectedLine(Contains("CI commit-05").Contains("<-- current")). Tap(markCommitAsBad). - SelectedLine(Contains("CI commit 04").Contains("<-- current")). + SelectedLine(Contains("CI commit-04").Contains("<-- current")). Tap(func() { markCommitAsGood() // commit 5 is the culprit because we marked 4 as good and 5 as bad. - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm() }). IsFocused(). - Content(Contains("CI commit 04")) + Content(Contains("CI commit-04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, diff --git a/pkg/integration/tests/bisect/choose_terms.go b/pkg/integration/tests/bisect/choose_terms.go index 51c9246ba..5e3b0ed27 100644 --- a/pkg/integration/tests/bisect/choose_terms.go +++ b/pkg/integration/tests/bisect/choose_terms.go @@ -34,40 +34,40 @@ var ChooseTerms = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). - SelectedLine(Contains("CI commit 10")). + SelectedLine(Contains("CI commit-10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(Contains("Choose bisect terms")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for old/good commit:")).Type("broken").Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for new/bad commit:")).Type("fixed").Confirm() }). - NavigateToLine(Contains("CI commit 09")). + NavigateToLine(Contains("CI commit-09")). Tap(markCommitAsFixed). SelectedLine(Contains("<-- fixed")). - NavigateToLine(Contains("CI commit 02")). + NavigateToLine(Contains("CI commit-02")). Tap(markCommitAsBroken). Lines( - Contains("CI commit 10").DoesNotContain("<--"), - Contains("CI commit 09").Contains("<-- fixed"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").DoesNotContain("<--"), - Contains("CI commit 05").Contains("<-- current").IsSelected(), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").Contains("<-- broken"), - Contains("CI commit 01").DoesNotContain("<--"), + Contains("CI commit-10").DoesNotContain("<--"), + Contains("CI commit-09").Contains("<-- fixed"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").DoesNotContain("<--"), + Contains("CI commit-05").Contains("<-- current").IsSelected(), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").Contains("<-- broken"), + Contains("CI commit-01").DoesNotContain("<--"), ). Tap(markCommitAsFixed). - SelectedLine(Contains("CI commit 04").Contains("<-- current")). + SelectedLine(Contains("CI commit-04").Contains("<-- current")). Tap(func() { markCommitAsBroken() // commit 5 is the culprit because we marked 4 as broken and 5 as fixed. - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm() }). IsFocused(). - Content(Contains("CI commit 04")) + Content(Contains("CI commit-04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, diff --git a/pkg/integration/tests/bisect/from_other_branch.go b/pkg/integration/tests/bisect/from_other_branch.go index 24e49104b..b65c88594 100644 --- a/pkg/integration/tests/bisect/from_other_branch.go +++ b/pkg/integration/tests/bisect/from_other_branch.go @@ -24,17 +24,17 @@ var FromOtherBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - MatchesRegexp(`<-- bad.*commit 08`), - MatchesRegexp(`<-- current.*commit 07`), - MatchesRegexp(`\?.*commit 06`), - MatchesRegexp(`<-- good.*commit 05`), + MatchesRegexp(`<-- bad.*commit-08`), + MatchesRegexp(`<-- current.*commit-07`), + MatchesRegexp(`\?.*commit-06`), + MatchesRegexp(`<-- good.*commit-05`), ). SelectNextItem(). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 08.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-08.*Do you want to reset")).Confirm() t.Views().Information().Content(DoesNotContain("Bisecting")) }). diff --git a/pkg/integration/tests/bisect/skip.go b/pkg/integration/tests/bisect/skip.go index c879cc408..7c9ef4aea 100644 --- a/pkg/integration/tests/bisect/skip.go +++ b/pkg/integration/tests/bisect/skip.go @@ -19,28 +19,28 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - SelectedLine(Contains("commit 10")). + SelectedLine(Contains("commit-10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm() }). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() t.Views().Information().Content(Contains("Bisecting")) }). Lines( - Contains("CI commit 10").Contains("<-- bad"), - Contains("CI commit 09").DoesNotContain("<--"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").DoesNotContain("<--"), - Contains("CI commit 05").Contains("<-- current").IsSelected(), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").DoesNotContain("<--"), - Contains("CI commit 01").Contains("<-- good"), + Contains("CI commit-10").Contains("<-- bad"), + Contains("CI commit-09").DoesNotContain("<--"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").DoesNotContain("<--"), + Contains("CI commit-05").Contains("<-- current").IsSelected(), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").DoesNotContain("<--"), + Contains("CI commit-01").Contains("<-- good"), ). Press(keys.Commits.ViewBisectOptions). Tap(func() { @@ -57,18 +57,18 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ }). // Skipping the current commit selects the new current commit: Lines( - Contains("CI commit 10").Contains("<-- bad"), - Contains("CI commit 09").DoesNotContain("<--"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").Contains("<-- current").IsSelected(), - Contains("CI commit 05").Contains("<-- skipped"), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").DoesNotContain("<--"), - Contains("CI commit 01").Contains("<-- good"), + Contains("CI commit-10").Contains("<-- bad"), + Contains("CI commit-09").DoesNotContain("<--"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").Contains("<-- current").IsSelected(), + Contains("CI commit-05").Contains("<-- skipped"), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").DoesNotContain("<--"), + Contains("CI commit-01").Contains("<-- good"), ). - NavigateToLine(Contains("commit 07")). + NavigateToLine(Contains("commit-07")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")). @@ -85,6 +85,6 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ }). // Skipping a selected, non-current commit keeps the selection // there: - SelectedLine(Contains("CI commit 07").Contains("<-- skipped")) + SelectedLine(Contains("CI commit-07").Contains("<-- skipped")) }, }) diff --git a/pkg/integration/tests/branch/select_commits_of_current_branch.go b/pkg/integration/tests/branch/select_commits_of_current_branch.go index 7b57455c3..6c1ee2a86 100644 --- a/pkg/integration/tests/branch/select_commits_of_current_branch.go +++ b/pkg/integration/tests/branch/select_commits_of_current_branch.go @@ -22,25 +22,25 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master 02"), Contains("master 01"), ). PressEscape(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ) @@ -58,15 +58,15 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().SubCommits(). IsFocused(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master 02"), Contains("master 01"), ) diff --git a/pkg/integration/tests/commit/create_amend_commit.go b/pkg/integration/tests/commit/create_amend_commit.go index 474e24099..7d311a983 100644 --- a/pkg/integration/tests/commit/create_amend_commit.go +++ b/pkg/integration/tests/commit/create_amend_commit.go @@ -19,11 +19,11 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -31,14 +31,14 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("amend! commit with changes")). Confirm() t.ExpectPopup().CommitMessagePanel(). - Content(Equals("commit 02")). + Content(Equals("commit-02")). Type(" amended").Confirm() }). Lines( - Contains("amend! commit 02"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("amend! commit-02"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Commits(). @@ -50,9 +50,9 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 02 amended").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02 amended").IsSelected(), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_commit.go b/pkg/integration/tests/custom_commands/selected_commit.go index 0265759a8..1add8b45a 100644 --- a/pkg/integration/tests/custom_commands/selected_commit.go +++ b/pkg/integration/tests/custom_commands/selected_commit.go @@ -24,44 +24,44 @@ var SelectedCommit = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { // Select different commits in each of the commit views t.Views().Commits().Focus(). - NavigateToLine(Contains("commit 01")) + NavigateToLine(Contains("commit-01")) t.Views().ReflogCommits().Focus(). - NavigateToLine(Contains("commit 02")) + NavigateToLine(Contains("commit-02")) t.Views().Branches().Focus(). Lines(Contains("master").IsSelected()). PressEnter() t.Views().SubCommits().IsFocused(). - NavigateToLine(Contains("commit 03")) + NavigateToLine(Contains("commit-03")) // SubCommits t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03")) + t.FileSystem().FileContent("file.txt", Equals("commit-03")) t.Views().SubCommits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03")) + t.FileSystem().FileContent("file.txt", Equals("commit-03")) // ReflogCommits t.Views().ReflogCommits().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) + t.FileSystem().FileContent("file.txt", Equals("commit: commit-02")) t.Views().ReflogCommits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) + t.FileSystem().FileContent("file.txt", Equals("commit: commit-02")) // LocalCommits t.Views().Commits().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) t.Views().Commits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) // None of these t.Views().Files().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_commit_range.go b/pkg/integration/tests/custom_commands/selected_commit_range.go index 6ef1305aa..662a28090 100644 --- a/pkg/integration/tests/custom_commands/selected_commit_range.go +++ b/pkg/integration/tests/custom_commands/selected_commit_range.go @@ -24,18 +24,18 @@ var SelectedCommitRange = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ) t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03\n")) + t.FileSystem().FileContent("file.txt", Equals("commit-03\n")) t.Views().Commits().Focus(). Press(keys.Universal.RangeSelectDown) t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03\ncommit 02\n")) + t.FileSystem().FileContent("file.txt", Equals("commit-03\ncommit-02\n")) }, }) diff --git a/pkg/integration/tests/filter_by_author/select_author.go b/pkg/integration/tests/filter_by_author/select_author.go index 281034c12..3e7759ce8 100644 --- a/pkg/integration/tests/filter_by_author/select_author.go +++ b/pkg/integration/tests/filter_by_author/select_author.go @@ -29,14 +29,14 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 7"), - Contains("commit 6"), - Contains("commit 5"), - Contains("commit 4"), - Contains("commit 3"), - Contains("commit 2"), - Contains("commit 1"), - Contains("commit 0"), + Contains("commit-7"), + Contains("commit-6"), + Contains("commit-5"), + Contains("commit-4"), + Contains("commit-3"), + Contains("commit-2"), + Contains("commit-1"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Paul Oberstein '")) @@ -51,7 +51,7 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). - NavigateToLine(Contains("SK commit 0")). + NavigateToLine(Contains("SK commit-0")). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). @@ -62,7 +62,7 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 0"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) diff --git a/pkg/integration/tests/filter_by_author/shared.go b/pkg/integration/tests/filter_by_author/shared.go index 22d08ad5c..33130db66 100644 --- a/pkg/integration/tests/filter_by_author/shared.go +++ b/pkg/integration/tests/filter_by_author/shared.go @@ -20,7 +20,7 @@ func commonSetup(shell *Shell) { for _, authorInfo := range authors { for i := range authorInfo.numberOfCommits { authorEmail := strings.ToLower(strings.ReplaceAll(authorInfo.name, " ", ".")) + "@email.com" - commitMessage := fmt.Sprintf("commit %d", i) + commitMessage := fmt.Sprintf("commit-%d", i) shell.SetAuthor(authorInfo.name, authorEmail) shell.EmptyCommitDaysAgo(commitMessage, repoStartDaysAgo-totalCommits) diff --git a/pkg/integration/tests/filter_by_author/type_author.go b/pkg/integration/tests/filter_by_author/type_author.go index cb84d5757..79750fab3 100644 --- a/pkg/integration/tests/filter_by_author/type_author.go +++ b/pkg/integration/tests/filter_by_author/type_author.go @@ -33,9 +33,9 @@ var TypeAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 2"), - Contains("commit 1"), - Contains("commit 0"), + Contains("commit-2"), + Contains("commit-1"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Yang Wen-li '")) @@ -58,7 +58,7 @@ var TypeAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 0"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) diff --git a/pkg/integration/tests/interactive_rebase/amend_first_commit.go b/pkg/integration/tests/interactive_rebase/amend_first_commit.go index 02ce4e112..b811a5638 100644 --- a/pkg/integration/tests/interactive_rebase/amend_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/amend_first_commit.go @@ -19,10 +19,10 @@ var AmendFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). @@ -31,8 +31,8 @@ var AmendFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go index 3140899be..8943f1580 100644 --- a/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go +++ b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go @@ -13,22 +13,22 @@ var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). - CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit 01"). + CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit-01"). CreateNCommitsStartingAt(2, 2). - CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit 03"). + CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit-03"). CreateFileAndAdd("fixup-file", "fixup 01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( - Contains("fixup! commit 03"), - Contains("commit 03"), - Contains("commit 02"), - Contains("fixup! commit 01"), - Contains("commit 01"), + Contains("fixup! commit-03"), + Contains("commit-03"), + Contains("commit-02"), + Contains("fixup! commit-01"), + Contains("commit-01"), ). - NavigateToLine(Contains("fixup! commit 01")). + NavigateToLine(Contains("fixup! commit-01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). @@ -37,11 +37,11 @@ var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("fixup! commit 03"), - Contains("commit 03"), - Contains("commit 02"), - Contains("fixup! commit 01").IsSelected(), - Contains("commit 01"), + Contains("fixup! commit-03"), + Contains("commit-03"), + Contains("commit-02"), + Contains("fixup! commit-01").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go index 66be297f0..0ca3ffaaa 100644 --- a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go @@ -17,18 +17,18 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -51,10 +51,10 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go index 1216655e8..3e4df1404 100644 --- a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go @@ -17,21 +17,21 @@ var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) - for _, commit := range []string{"commit 01", "commit 03"} { + for _, commit := range []string{"commit-01", "commit-03"} { t.Views().Commits(). NavigateToLine(Contains(commit)). Press(keys.Commits.AmendToCommit) diff --git a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go index 3b7642bf6..86b2ed950 100644 --- a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go @@ -23,18 +23,18 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 03"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Universal.Remove). @@ -46,25 +46,25 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), - Contains("pick").Contains("CI commit 03").IsSelected(), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03").IsSelected(), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Remove). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 06"), - Contains("CI ○ commit 05"), - Contains("CI ○ commit 04"), - Contains("CI ○ commit 03"), // No star on this commit, so there's no branch head here - Contains("CI ○ commit 01"), + Contains("CI ○ commit-06"), + Contains("CI ○ commit-05"), + Contains("CI ○ commit-04"), + Contains("CI ○ commit-03"), // No star on this commit, so there's no branch head here + Contains("CI ○ commit-01"), ) t.Views().Branches(). diff --git a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go index e5b43ee81..1b8674593 100644 --- a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go +++ b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go @@ -28,31 +28,31 @@ var DontShowBranchHeadsForTodoItems = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 09"), - Contains("CI commit 08"), - Contains("CI commit 07"), - Contains("CI * commit 06"), - Contains("CI commit 05"), - Contains("CI commit 04"), - Contains("CI commit 03"), - Contains("CI * commit 02"), - Contains("CI commit 01"), + Contains("CI commit-09"), + Contains("CI commit-08"), + Contains("CI commit-07"), + Contains("CI * commit-06"), + Contains("CI commit-05"), + Contains("CI commit-04"), + Contains("CI commit-03"), + Contains("CI * commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 09"), - Contains("pick").Contains("CI commit 08"), - Contains("pick").Contains("CI commit 07"), + Contains("pick").Contains("CI commit-09"), + Contains("pick").Contains("CI commit-08"), + Contains("pick").Contains("CI commit-07"), Contains("update-ref").Contains("branch2"), - Contains("pick").Contains("CI commit 06"), // no star on this entry, even though branch2 points to it - Contains("pick").Contains("CI commit 05"), + Contains("pick").Contains("CI commit-06"), // no star on this entry, even though branch2 points to it + Contains("pick").Contains("CI commit-05"), Contains("--- Commits ---"), - Contains("CI commit 04"), - Contains("CI commit 03"), - Contains("CI * commit 02"), // this star is fine though - Contains("CI commit 01"), + Contains("CI commit-04"), + Contains("CI commit-03"), + Contains("CI * commit-02"), // this star is fine though + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go index 81462296b..b66dcd2d7 100644 --- a/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go @@ -25,11 +25,11 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Focus(). Lines( - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). @@ -38,8 +38,8 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes Confirm() }). Lines( - Contains("CI commit 03"), // no start on this commit because branch1 is no longer pointing to it - Contains("CI commit 01"), + Contains("CI commit-03"), // no start on this commit because branch1 is no longer pointing to it + Contains("CI commit-01"), ) t.Views().Branches(). @@ -48,9 +48,9 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes PressPrimaryAction() t.Views().Commits().Lines( - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go index ca481e986..9fb450afe 100644 --- a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go @@ -28,32 +28,32 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 07").IsSelected(), - Contains("CI commit 06"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07").IsSelected(), + Contains("CI commit-06"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 07"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), + Contains("pick").Contains("CI commit-07"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), Contains("update-ref").Contains("branch1").DoesNotContain("*"), - Contains("pick").Contains("CI commit 04"), - Contains("pick").Contains("CI commit 03"), + Contains("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03"), Contains("--- Commits ---"), - Contains("CI commit 02").IsSelected(), - Contains("CI commit 01"), + Contains("CI commit-02").IsSelected(), + Contains("CI commit-01"), ). Tap(func() { - t.Views().Main().Content(Contains("commit 02")) + t.Views().Main().Content(Contains("commit-02")) }). - NavigateToLine(Contains("commit 06")). + NavigateToLine(Contains("commit-06")). Press(keys.Universal.Remove) t.Common().ContinueRebase() @@ -61,12 +61,12 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("CI commit 07"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go index a6868e44f..734567d00 100644 --- a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go +++ b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go @@ -17,8 +17,8 @@ var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Universal.Remove). Tap(func() { @@ -28,7 +28,7 @@ var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go index 2107c8a58..3045a5088 100644 --- a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go +++ b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go @@ -18,18 +18,18 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -46,9 +46,9 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/edit_first_commit.go b/pkg/integration/tests/interactive_rebase/edit_first_commit.go index f09b7f27d..2ba657370 100644 --- a/pkg/integration/tests/interactive_rebase/edit_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_first_commit.go @@ -18,23 +18,23 @@ var EditFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02"), + Contains("commit-02"), Contains("--- Commits ---"), - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go index 528afb7a4..7db9bb262 100644 --- a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go @@ -28,23 +28,23 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("--- Commits ---"), - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -66,11 +66,11 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05"), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go index 6a21412de..00f77594e 100644 --- a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go @@ -18,17 +18,17 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit) t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) diff --git a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go index 832b99652..a57ab8acd 100644 --- a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go @@ -19,8 +19,8 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT t.Views().Commits(). Focus(). TopLines( - Contains("CI ○ commit 02").IsSelected(), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-02").IsSelected(), + Contains("CI ○ commit-01"), Contains("Merge branch 'second-change-branch' into first-change-branch"), ). Press(keys.Universal.RangeSelectDown). @@ -28,8 +28,8 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("edit CI commit 02").IsSelected(), - Contains("edit CI commit 01").IsSelected(), + Contains("edit CI commit-02").IsSelected(), + Contains("edit CI commit-01").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains(" CI ◎─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains(" CI │ ○ * second-change-branch unrelated change"), diff --git a/pkg/integration/tests/interactive_rebase/fixup_first_commit.go b/pkg/integration/tests/interactive_rebase/fixup_first_commit.go index ff099d760..9dc90c5c7 100644 --- a/pkg/integration/tests/interactive_rebase/fixup_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/fixup_first_commit.go @@ -18,17 +18,17 @@ var FixupFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go index 73ace9105..de0bb28d0 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go @@ -25,19 +25,19 @@ var InteractiveRebaseOfCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), // No update-ref todo for branch1 here, even though command-line git would have added it - Contains("pick").Contains("CI commit 03"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go index 11596e758..5b341e61c 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go @@ -24,9 +24,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat Focus(). Lines( Contains("this will conflict").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("initial commit"), ) @@ -55,9 +55,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat Contains("--- Pending rebase todos ---"), Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(), Contains("--- Commits ---"), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit"), Contains("initial commit"), ) diff --git a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go index cb96b8308..95dab3056 100644 --- a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go @@ -18,95 +18,95 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("commit 10").IsSelected(), + Contains("commit-10").IsSelected(), ). - NavigateToLine(Contains("commit 05")). + NavigateToLine(Contains("commit-05")). // Start a rebase Press(keys.Universal.Edit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("pick").Contains("commit 07"), - Contains("pick").Contains("commit 06"), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("pick").Contains("commit-07"), + Contains("pick").Contains("commit-06"), Contains("--- Commits ---"), - Contains("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). SelectPreviousItem(). // perform various actions on a range of commits Press(keys.Universal.RangeSelectUp). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("pick").Contains("commit 07").IsSelected(), - Contains("pick").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("pick").Contains("commit-07").IsSelected(), + Contains("pick").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("fixup").Contains("commit 07").IsSelected(), - Contains("fixup").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("fixup").Contains("commit-07").IsSelected(), + Contains("fixup").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.PickCommit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("pick").Contains("commit 07").IsSelected(), - Contains("pick").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("pick").Contains("commit-07").IsSelected(), + Contains("pick").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Universal.Edit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("edit").Contains("commit 07").IsSelected(), - Contains("edit").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("edit").Contains("commit-07").IsSelected(), + Contains("edit").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.SquashDown). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveDownCommit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) @@ -114,38 +114,38 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), - Contains("pick").Contains("commit 08"), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), + Contains("pick").Contains("commit-08"), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 10"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), + Contains("pick").Contains("commit-10"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { @@ -153,29 +153,29 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). TopLines( Contains("--- Pending rebase todos ---"), - Contains("squash").Contains("commit 07").IsSelected(), - Contains("squash").Contains("commit 06").IsSelected(), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08"), + Contains("squash").Contains("commit-07").IsSelected(), + Contains("squash").Contains("commit-06").IsSelected(), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08"), Contains("--- Commits ---"), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). // Verify we can't perform an action on a range that includes both // TODO and non-TODO commits - NavigateToLine(Contains("commit 08")). + NavigateToLine(Contains("commit-08")). Press(keys.Universal.RangeSelectDown). TopLines( Contains("--- Pending rebase todos ---"), - Contains("squash").Contains("commit 07"), - Contains("squash").Contains("commit 06"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08").IsSelected(), + Contains("squash").Contains("commit-07"), + Contains("squash").Contains("commit-06"), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08").IsSelected(), Contains("--- Commits ---").IsSelected(), - Contains("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { @@ -183,28 +183,28 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). TopLines( Contains("--- Pending rebase todos ---"), - Contains("squash").Contains("commit 07"), - Contains("squash").Contains("commit 06"), - Contains("pick").Contains("commit 10"), - Contains("pick").Contains("commit 09"), - Contains("pick").Contains("commit 08").IsSelected(), + Contains("squash").Contains("commit-07"), + Contains("squash").Contains("commit-06"), + Contains("pick").Contains("commit-10"), + Contains("pick").Contains("commit-09"), + Contains("pick").Contains("commit-08").IsSelected(), Contains("--- Commits ---").IsSelected(), - Contains("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). // continue the rebase Tap(func() { t.Common().ContinueRebase() }). TopLines( - Contains("commit 10"), - Contains("commit 09"), - Contains("commit 08"), - Contains("commit 05"), + Contains("commit-10"), + Contains("commit-09"), + Contains("commit-08"), + Contains("commit-05"), // selected indexes are retained, though we may want to clear it // in future (not sure what the best behaviour is right now) - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move.go b/pkg/integration/tests/interactive_rebase/move.go index 3f1f23755..4f37f2c19 100644 --- a/pkg/integration/tests/interactive_rebase/move.go +++ b/pkg/integration/tests/interactive_rebase/move.go @@ -17,31 +17,31 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 04").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-04").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), ). // assert nothing happens upon trying to move beyond the last commit Press(keys.Commits.MoveDownCommit). @@ -49,31 +49,31 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 03"), - Contains("commit 04").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-04").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). // assert nothing happens upon trying to move beyond the first commit Press(keys.Commits.MoveUpCommit). @@ -81,10 +81,10 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go b/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go index 0f341d5b5..36f1f5312 100644 --- a/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go @@ -28,20 +28,20 @@ var MoveAcrossBranchBoundaryOutsideRebase = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Commits.MoveDownCommit). Lines( - Contains("CI commit 05"), - Contains("CI * commit 03"), - Contains("CI commit 04").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05"), + Contains("CI * commit-03"), + Contains("CI commit-04").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_in_rebase.go b/pkg/integration/tests/interactive_rebase/move_in_rebase.go index 1cc9dd785..9138839b6 100644 --- a/pkg/integration/tests/interactive_rebase/move_in_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_in_rebase.go @@ -17,39 +17,39 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(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("commit-04"), + Contains("commit-03"), + Contains("commit-02"), Contains("--- Commits ---"), - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02").IsSelected(), - Contains("commit 04"), - Contains("commit 03"), + Contains("commit-02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). // assert we can't move past the top Press(keys.Commits.MoveUpCommit). @@ -58,29 +58,29 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02").IsSelected(), - Contains("commit 04"), - Contains("commit 03"), + Contains("commit-02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). // assert we can't move past the bottom Press(keys.Commits.MoveDownCommit). @@ -89,30 +89,30 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). // move it back up one so that we land in a different order than we started with Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), - Contains("commit 01"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go index 619efe7fb..c730fd995 100644 --- a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go @@ -23,43 +23,43 @@ var MoveUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 03"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Commits.MoveUpCommit). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 06"), + Contains("pick").Contains("CI commit-06"), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), - Contains("pick").Contains("CI commit 03"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 06"), - Contains("CI ○ * commit 05"), - Contains("CI ○ commit 04"), - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-06"), + Contains("CI ○ * commit-05"), + Contains("CI ○ commit-04"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go b/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go index eefbcea33..db5177cff 100644 --- a/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go +++ b/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go @@ -17,18 +17,18 @@ var MoveWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 01"), - Contains("commit 02").IsSelected(), + Contains("commit-01"), + Contains("commit-02").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go index 4aeb28b28..fe9d2b762 100644 --- a/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go @@ -18,13 +18,13 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("commit 10").IsSelected(), + Contains("commit-10").IsSelected(), ). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 10").IsSelected(), - Contains("commit 09").IsSelected(), - Contains("commit 08"), + Contains("commit-10").IsSelected(), + Contains("commit-09").IsSelected(), + Contains("commit-08"), ). // Drop commits Press(keys.Universal.Remove). @@ -35,14 +35,14 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 08").IsSelected(), - Contains("commit 07"), + Contains("commit-08").IsSelected(), + Contains("commit-07"), ). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 08").IsSelected(), - Contains("commit 07").IsSelected(), - Contains("commit 06"), + Contains("commit-08").IsSelected(), + Contains("commit-07").IsSelected(), + Contains("commit-06"), ). // Squash commits Press(keys.Commits.SquashDown). @@ -53,27 +53,27 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 06").IsSelected(), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-06").IsSelected(), + Contains("commit-05"), + Contains("commit-04"), ). // Verify commit messages are concatenated Tap(func() { t.Views().Main(). ContainsLines( - Contains("commit 06"), + Contains("commit-06"), AnyString(), - Contains("commit 07"), + Contains("commit-07"), AnyString(), - Contains("commit 08"), + Contains("commit-08"), ) }). // Fixup commits Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 06").IsSelected(), - Contains("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-06").IsSelected(), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { @@ -82,73 +82,73 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), ). // Verify commit messages are dropped Tap(func() { t.Views().Main(). Content( - Contains("commit 04"). - DoesNotContain("commit 06"). - DoesNotContain("commit 05"), + Contains("commit-04"). + DoesNotContain("commit-06"). + DoesNotContain("commit-05"), ) }). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), ). // Move commits Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go index 55be5ea4a..4d045c8fc 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go @@ -28,27 +28,27 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 07").IsSelected(), - Contains("CI commit 06"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07").IsSelected(), + Contains("CI commit-06"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 07"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), + Contains("pick").Contains("CI commit-07"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 04"), - Contains("pick").Contains("CI commit 03"), - Contains("CI commit 02").IsSelected(), + Contains("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03"), + Contains("CI commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go index 8ff8f1065..4d25a883c 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go @@ -29,31 +29,31 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Lines( - Contains("CI commit 07"), - Contains("CI commit 06"), - Contains("CI * commit 05"), - Contains("CI commit 04").IsSelected(), - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02").IsSelected(), - Contains("CI commit 01"), + Contains("CI commit-07"), + Contains("CI commit-06"), + Contains("CI * commit-05"), + Contains("CI commit-04").IsSelected(), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02").IsSelected(), + Contains("CI commit-01"), ). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("CI commit 07"), - Contains("CI commit 06"), + Contains("CI commit-07"), + Contains("CI commit-06"), Contains("update-ref").Contains("branch2"), - Contains("CI commit 05"), - Contains("CI commit 04").IsSelected(), + Contains("CI commit-05"), + Contains("CI commit-04").IsSelected(), Contains("update-ref").Contains("branch1").IsSelected(), - Contains("CI commit 03").IsSelected(), - Contains("CI commit 02").IsSelected(), + Contains("CI commit-03").IsSelected(), + Contains("CI commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go index 16a2b8c25..44a39200b 100644 --- a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go +++ b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go @@ -20,22 +20,22 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit 2"), Contains("master commit 1"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 04"), + Contains("pick").Contains("commit-04"), Contains("--- Commits ---"), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit 2"), Contains("master commit 1"), ). @@ -50,13 +50,13 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA }). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 04"), + Contains("pick").Contains("commit-04"), Contains("--- Commits ---"), - Contains(`Revert "commit 01"`), - Contains(`Revert "commit 02"`), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains(`Revert "commit-01"`), + Contains(`Revert "commit-02"`), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master commit 2"), Contains("master commit 1"), ) diff --git a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go index df6486772..b8cf20ae8 100644 --- a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go +++ b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go @@ -20,11 +20,11 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { t.ExpectPopup().Confirmation(). @@ -34,10 +34,10 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.ExpectPopup().Alert(). diff --git a/pkg/integration/tests/interactive_rebase/reword_first_commit.go b/pkg/integration/tests/interactive_rebase/reword_first_commit.go index cb9afc3c4..b61ceb93a 100644 --- a/pkg/integration/tests/interactive_rebase/reword_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_first_commit.go @@ -21,21 +21,21 @@ var RewordFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 01")). + InitialText(Equals("commit-01")). Clear(). Type("renamed 01"). Confirm() }). Lines( - Contains("commit 02"), + Contains("commit-02"), Contains("renamed 01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/reword_last_commit.go b/pkg/integration/tests/interactive_rebase/reword_last_commit.go index 5d3038feb..80a57cc32 100644 --- a/pkg/integration/tests/interactive_rebase/reword_last_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_last_commit.go @@ -18,21 +18,21 @@ var RewordLastCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 02")). + InitialText(Equals("commit-02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("renamed 02"), - Contains("commit 01"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go b/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go index e9cdc3a1a..b353e69cc 100644 --- a/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go @@ -28,28 +28,28 @@ var RewordLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 03")). + InitialText(Equals("commit-03")). Clear(). Type("renamed 03"). Confirm() }). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), + Contains("CI commit-05"), + Contains("CI commit-04"), Contains("CI * renamed 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go index 92aaf1a43..bd58ab083 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go @@ -18,34 +18,34 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 02")). + InitialText(Equals("commit-02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), - Contains("commit 01"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go index b927684fe..5406ed02b 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go @@ -20,18 +20,18 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { @@ -42,10 +42,10 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), - Contains("commit 01"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index 948bfb7d8..fad0e44e8 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -33,10 +33,10 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("--- Pending rebase todos ---"), Contains("exec").Contains("false"), - Contains("pick").Contains("CI commit 03"), + Contains("pick").Contains("CI commit-03"), Contains("--- Commits ---"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() @@ -45,17 +45,17 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go b/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go index 65d6bfaa7..97f3f3567 100644 --- a/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go @@ -18,17 +18,17 @@ var SquashDownFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go index 6ba313f7a..ba5f33705 100644 --- a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go @@ -18,11 +18,11 @@ var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectPopup().Confirmation(). @@ -31,12 +31,12 @@ var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 01").IsSelected(), + Contains("commit-03"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). - Content(Contains(" commit 01\n \n commit 02")). + Content(Contains(" commit-01\n \n commit-02")). Content(Contains("+file01 content")). Content(Contains("+file02 content")) }, diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_above.go b/pkg/integration/tests/interactive_rebase/squash_fixups_above.go index 467a66154..fdbcf7817 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_above.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_above.go @@ -19,11 +19,11 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -32,10 +32,10 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("fixup! commit 02"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("fixup! commit-02"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.SquashAboveCommits). Tap(func() { @@ -45,9 +45,9 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go b/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go index 2d71093ba..4786dfd72 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go @@ -19,10 +19,10 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -30,7 +30,7 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("fixup! commit")). Confirm() }). - NavigateToLine(Contains("commit 01").DoesNotContain("fixup!")). + NavigateToLine(Contains("commit-01").DoesNotContain("fixup!")). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). @@ -39,8 +39,8 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go b/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go index c6721d829..7e9caeebf 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go @@ -22,7 +22,7 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Commit("fixup! master commit"). CreateNCommits(2). CreateFileAndAdd("fixup-file", "fixup content"). - Commit("fixup! commit 01") + Commit("fixup! commit-01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). @@ -30,9 +30,9 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). SelectNextItem(). Lines( - Contains("fixup! commit 01"), - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("fixup! commit-01"), + Contains("commit-02"), + Contains("commit-01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ). @@ -44,8 +44,8 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ) diff --git a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go index f52e80703..3746633c7 100644 --- a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go +++ b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go @@ -29,11 +29,11 @@ var ViewFilesOfTodoEntries = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 03").IsSelected(), + Contains("pick").Contains("CI commit-03").IsSelected(), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ). Press(keys.Universal.GoInto) diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go b/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go index c9fd80d0e..67170b35a 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go @@ -15,12 +15,12 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati }, SetupRepo: func(shell *Shell) { shell. - EmptyCommit("commit 01"). + EmptyCommit("commit-01"). NewBranch("branch1"). - EmptyCommit("commit 02"). + EmptyCommit("commit-02"). CreateFileAndAdd("file1", "file1 content"). CreateFileAndAdd("file2", "file2 content"). - Commit("commit 03"). + Commit("commit-03"). NewBranch("branch2"). CreateNCommitsStartingAt(2, 4) @@ -30,13 +30,13 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). PressEnter() t.Views().CommitFiles(). @@ -61,12 +61,12 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati t.Views().Commits(). IsFocused(). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), + Contains("CI commit-05"), + Contains("CI commit-04"), Contains("CI * new commit").IsSelected(), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/ui/accordion.go b/pkg/integration/tests/ui/accordion.go index 1e2ed1480..0a18d2881 100644 --- a/pkg/integration/tests/ui/accordion.go +++ b/pkg/integration/tests/ui/accordion.go @@ -11,9 +11,9 @@ import ( // ╶─Files - Submodules──────0 of 0─╴│commit 6e56dd04b70e548976f7f2928c4d9c359574e2bc ▲ // ╶─Local branches - Remotes1 of 1─╴│Author: CI █ // ┌─Commits - Reflog───────────────┐│Date: Wed Jul 19 22:00:03 2023 +1000 │ -// │7fe02805 CI commit 12 ▲│ ▼ -// │6e56dd04 CI commit 11 █└────────────────────────────────────────────────────────────────┘ -// │a35c687d CI commit 10 ▼┌─Command log────────────────────────────────────────────────────┐ +// │7fe02805 CI commit-12 ▲│ ▼ +// │6e56dd04 CI commit-11 █└────────────────────────────────────────────────────────────────┘ +// │a35c687d CI commit-10 ▼┌─Command log────────────────────────────────────────────────────┐ // └───────────────────────10 of 20─┘│Random tip: To filter commits by path, press '' │ // ╶─Stash───────────────────0 of 0─╴└────────────────────────────────────────────────────────────────┘ // /: Scroll, : Cancel, q: Quit, ?: Keybindings, 1-Donate Ask Question unversioned @@ -32,18 +32,18 @@ var Accordion = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). VisibleLines( - Contains("commit 20").IsSelected(), - Contains("commit 19"), - Contains("commit 18"), + Contains("commit-20").IsSelected(), + Contains("commit-19"), + Contains("commit-18"), ). // go past commit 11, then come back, so that it ends up in the centre of the viewport - NavigateToLine(Contains("commit 11")). - NavigateToLine(Contains("commit 10")). - NavigateToLine(Contains("commit 11")). + NavigateToLine(Contains("commit-11")). + NavigateToLine(Contains("commit-10")). + NavigateToLine(Contains("commit-11")). VisibleLines( - Contains("commit 12"), - Contains("commit 11").IsSelected(), - Contains("commit 10"), + Contains("commit-12"), + Contains("commit-11").IsSelected(), + Contains("commit-10"), ) t.Views().Files(). @@ -53,9 +53,9 @@ var Accordion = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). VisibleLines( - Contains("commit 12"), - Contains("commit 11").IsSelected(), - Contains("commit 10"), + Contains("commit-12"), + Contains("commit-11").IsSelected(), + Contains("commit-10"), ) }, }) diff --git a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go index 73f09da3c..550cac2b5 100644 --- a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go +++ b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go @@ -27,8 +27,8 @@ var ModeSpecificKeybindingSuggestions = NewIntegrationTest(NewIntegrationTestArg t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Tap(func() { // These suggestions are mode-specific so are not shown by default From c21ce617298f11e0ac4733a43d26917197612518 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:28:29 +0200 Subject: [PATCH 07/94] Synchronize ViewBufferManager.Close with a starting task Close read and called stopCurrentTask with no lock, while NewTask's goroutine assigns it (and constructs the sync.Once it closes over) under waitingMutex. On shutdown Close runs while a render task spawned by the last layout is still starting, so the two raced on the field and the once (three DATA RACE blocks under -race, e.g. cherry_pick). Read stopCurrentTask once under waitingMutex and call the captured value instead of re-reading the field, which establishes the happens-before the once needs. This can't deadlock: no task holds waitingMutex across a blocking UI-thread hop, so Close can always take it, and a task wedged in such a hop is still bounded by the existing 3s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index bc08013ed..9da12d40b 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -393,14 +393,21 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // Close closes the task manager, killing whatever task may currently be running func (self *ViewBufferManager) Close() { - if self.stopCurrentTask == nil { + // stopCurrentTask is written by NewTask's goroutine under waitingMutex (and + // so is the sync.Once it closes over), so read it under the lock and call + // the captured value; a task starting on shutdown must not race us here. + self.waitingMutex.Lock() + stopCurrentTask := self.stopCurrentTask + self.waitingMutex.Unlock() + + if stopCurrentTask == nil { return } c := make(chan struct{}) go utils.Safe(func() { - self.stopCurrentTask() + stopCurrentTask() c <- struct{}{} }) From d4a606c68503cc7f8e0e9e5c4de0c3b344ca59b8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:19:29 +0200 Subject: [PATCH 08/94] Demonstrate that list index conversions depend on rendering ModelIndexToViewIndex and ViewIndexToModelIndex read conversion arrays that only renderLines populates. So converting an index before the list has been rendered ignores the non-model items (e.g. section headers) and returns a wrong result; the same staleness makes a conversion after the model has grown index a too-short array and panic (seen in cherry_pick under -race). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/list_renderer_test.go | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/gui/context/list_renderer_test.go b/pkg/gui/context/list_renderer_test.go index 08af680ff..24e4aa822 100644 --- a/pkg/gui/context/list_renderer_test.go +++ b/pkg/gui/context/list_renderer_test.go @@ -267,3 +267,31 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { }) } } + +// The index conversions must not depend on the list having been rendered +// first. It used to be renderLines that populated the conversion arrays, so +// converting an index before the first render silently ignored the non-model +// items (and converting after the model changed used a stale snapshot). +func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) { + modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) }) + self := &ListRenderer{ + list: NewListViewModel(func() []myint { return modelInts }), + getDisplayStrings: func(startIdx int, endIdx int) [][]string { + return lo.Map(modelInts[startIdx:endIdx], + func(i myint, _ int) []string { return []string{fmt.Sprint(i)} }) + }, + // A section header sits at model index 1, so model item 1 is pushed down + // to view index 2, and view index 2 maps back to model item 1. + getNonModelItems: func() []*NonModelItem { + return []*NonModelItem{{Index: 1, Content: "--- header ---"}} + }, + } + + // Deliberately convert without rendering first. + /* EXPECTED: + assert.Equal(t, 2, self.ModelIndexToViewIndex(1)) + assert.Equal(t, 1, self.ViewIndexToModelIndex(2)) + ACTUAL: */ + assert.Equal(t, 1, self.ModelIndexToViewIndex(1)) + assert.Equal(t, 2, self.ViewIndexToModelIndex(2)) +} From 4e907c6b3e00d22fa954b10a9fd97bfb1f601c7b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:25:25 +0200 Subject: [PATCH 09/94] Compute list index conversions independently of rendering The model<->view index conversions were derived from arrays that only renderLines populated. That made them depend on the list having been rendered (so a conversion before the first render ignored the non-model items), and it made them go stale whenever the model changed after a render: converting an index then returned a wrong result, and once the model had grown past the last rendered length the conversion indexed a too-short array and panicked (seen in cherry_pick under -race). The conversion is a pure function of the current list length and the current non-model items, and needs none of the rendered display strings. Compute it directly and drop the cached arrays, so the result is always consistent with the current model and no longer depends on rendering. searchModelCommits converts every commit's index, and building the non-model items can be O(len) mid-rebase, so it would now be quadratic; snapshot the non-model items once via modelToViewIndexConverter instead of rebuilding them per index. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/list_renderer.go | 96 ++++++++++++++---------- pkg/gui/context/list_renderer_test.go | 7 -- pkg/gui/context/local_commits_context.go | 2 +- pkg/gui/context/sub_commits_context.go | 2 +- 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/pkg/gui/context/list_renderer.go b/pkg/gui/context/list_renderer.go index e863045e0..b8d036778 100644 --- a/pkg/gui/context/list_renderer.go +++ b/pkg/gui/context/list_renderer.go @@ -32,34 +32,76 @@ type ListRenderer struct { getNonModelItems func() []*NonModelItem // The remaining fields are private and shouldn't be initialized by clients - numNonModelItems int - viewIndicesByModelIndex []int - modelIndicesByViewIndex []int - columnPositions []int + columnPositions []int } func (self *ListRenderer) GetList() types.IList { return self.list } -func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int { - modelIndex = lo.Clamp(modelIndex, 0, self.list.Len()) - if self.viewIndicesByModelIndex != nil { - return self.viewIndicesByModelIndex[modelIndex] +func (self *ListRenderer) getNonModelItemList() []*NonModelItem { + if self.getNonModelItems == nil { + return nil } + return self.getNonModelItems() +} - return modelIndex +func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int { + return modelIndexToViewIndex(self.list.Len(), self.getNonModelItemList(), modelIndex) } func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int { - viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems) - if self.modelIndicesByViewIndex != nil { - return self.modelIndicesByViewIndex[viewIndex] - } + return viewIndexToModelIndex(self.list.Len(), self.getNonModelItemList(), viewIndex) +} +// modelToViewIndexConverter returns a model-to-view index conversion that +// reuses a single snapshot of the non-model items. Callers that convert many +// indices in a row (e.g. search, which converts every commit) should use this +// rather than calling ModelIndexToViewIndex per index, which would rebuild the +// non-model items each time. +func (self *ListRenderer) modelToViewIndexConverter() func(modelIndex int) int { + listLength := self.list.Len() + nonModelItems := self.getNonModelItemList() + return func(modelIndex int) int { + return modelIndexToViewIndex(listLength, nonModelItems, modelIndex) + } +} + +// The view shows the model items with the non-model items (e.g. section +// headers) inserted at their model indices. The two conversions below are +// computed directly from the current list length and non-model items, so they +// don't depend on the list having been rendered, and they can never be stale +// with respect to a model that changed since the last render (which used to +// cause both wrong results and index-out-of-range panics). +// +// The non-model items are assumed to be ordered by their Index, which is how +// all producers build them; the i-th one therefore ends up at view index +// Index+i. +func modelIndexToViewIndex(listLength int, nonModelItems []*NonModelItem, modelIndex int) int { + modelIndex = lo.Clamp(modelIndex, 0, listLength) + // Each non-model item inserted at or before this model item pushes it down + // by one row in the view. + viewIndex := modelIndex + for _, item := range nonModelItems { + if item.Index <= modelIndex { + viewIndex++ + } + } return viewIndex } +func viewIndexToModelIndex(listLength int, nonModelItems []*NonModelItem, viewIndex int) int { + viewIndex = lo.Clamp(viewIndex, 0, listLength+len(nonModelItems)) + // Subtract the non-model items that appear before this view index. + modelIndex := viewIndex + for i, item := range nonModelItems { + if item.Index+i < viewIndex { + modelIndex-- + } + } + return modelIndex +} + func (self *ListRenderer) ColumnPositions() []int { return self.columnPositions } @@ -71,23 +113,18 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string { if self.getColumnAlignments != nil { columnAlignments = self.getColumnAlignments() } - nonModelItems := []*NonModelItem{} - self.numNonModelItems = 0 - if self.getNonModelItems != nil { - nonModelItems = self.getNonModelItems() - self.prepareConversionArrays(nonModelItems) - } + nonModelItems := self.getNonModelItemList() startModelIdx := 0 if startIdx == -1 { startIdx = 0 } else { - startModelIdx = self.ViewIndexToModelIndex(startIdx) + startModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, startIdx) } endModelIdx := self.list.Len() if endIdx == -1 { endIdx = endModelIdx + len(nonModelItems) } else { - endModelIdx = self.ViewIndexToModelIndex(endIdx) + endModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, endIdx) } lines, columnPositions := utils.RenderDisplayStrings( self.getDisplayStrings(startModelIdx, endModelIdx), @@ -97,23 +134,6 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string { return strings.Join(lines, "\n") } -func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) { - self.numNonModelItems = len(nonModelItems) - viewIndicesByModelIndex := lo.Range(self.list.Len() + 1) - modelIndicesByViewIndex := lo.Range(self.list.Len() + 1) - offset := 0 - for _, item := range nonModelItems { - for i := item.Index; i <= self.list.Len(); i++ { - viewIndicesByModelIndex[i]++ - } - modelIndicesByViewIndex = slices.Insert( - modelIndicesByViewIndex, item.Index+offset, modelIndicesByViewIndex[item.Index+offset]) - offset++ - } - self.viewIndicesByModelIndex = viewIndicesByModelIndex - self.modelIndicesByViewIndex = modelIndicesByViewIndex -} - func (self *ListRenderer) insertNonModelItems( nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int, ) []string { diff --git a/pkg/gui/context/list_renderer_test.go b/pkg/gui/context/list_renderer_test.go index 24e4aa822..11398a995 100644 --- a/pkg/gui/context/list_renderer_test.go +++ b/pkg/gui/context/list_renderer_test.go @@ -254,9 +254,6 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { getNonModelItems: getNonModelItems, } - // Need to render first so that it knows the non-model items - self.renderLines(-1, -1) - for i := range len(s.modelIndices) { assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i])) } @@ -288,10 +285,6 @@ func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) { } // Deliberately convert without rendering first. - /* EXPECTED: assert.Equal(t, 2, self.ModelIndexToViewIndex(1)) assert.Equal(t, 1, self.ViewIndexToModelIndex(2)) - ACTUAL: */ - assert.Equal(t, 1, self.ModelIndexToViewIndex(1)) - assert.Equal(t, 2, self.ViewIndexToModelIndex(2)) } diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index d929aca88..a66c720c9 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -224,7 +224,7 @@ func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string { } func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr) + return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr) } func (self *LocalCommitsViewModel) SetLimitCommits(value bool) { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index fee5492ac..4e05c9594 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -223,7 +223,7 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string { } func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr) + return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr) } func (self *SubCommitsContext) IndexForGotoBottom() int { From c81c08071f8fbeaa755032b97204eac217dae4fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 15:41:58 +0200 Subject: [PATCH 10/94] Add a hint about how to use diff --color-words or --word-diff in lazygit Since this frequently comes up as a feature request (but there are reasons why we don't want to add it), explain how to do this in lazygit today. --- docs-master/Custom_Pagers.md | 16 ++++++++++++++++ docs/Custom_Pagers.md | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index 1b37766d0..f74005c19 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -79,6 +79,22 @@ git: - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off ``` +This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: + +```sh +#!/bin/sh + +git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +``` + +And then use it in your git config like so: + +```yaml +git: + pagers: + - externalDiffCommand: ~/bin/color-words.sh +``` + Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using ```yaml diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md index 1b37766d0..f74005c19 100644 --- a/docs/Custom_Pagers.md +++ b/docs/Custom_Pagers.md @@ -79,6 +79,22 @@ git: - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off ``` +This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: + +```sh +#!/bin/sh + +git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +``` + +And then use it in your git config like so: + +```yaml +git: + pagers: + - externalDiffCommand: ~/bin/color-words.sh +``` + Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using ```yaml From 58e121b9330836c3201f73f8246787ef67536345 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 16:48:42 +0200 Subject: [PATCH 11/94] Don't block the UI thread when triggering an immediate fetch on repo switch Switching repos triggers an immediate background fetch by sending on the goEvery retrigger channel. The send was blocking, but the goEvery loop only receives between callbacks: while a fetch is in flight, it waits for that fetch to finish before returning to its select. So a repo switch that landed while a fetch was in flight would stall the UI thread for the remainder of the fetch. Worse, since worker refreshes capture state on the UI thread with a blocking OnUIThreadAndWaitBackground call, the in-flight fetch's post-fetch refresh can itself be waiting for the UI thread, turning that stall into a deadlock cycle: UI thread: switchTo -> triggerImmediateFetch, blocking send goEvery loop: waiting for the in-flight fetch to finish fetch worker: PostFetchRefresh -> RefreshFromWorker, waiting for the UI thread Make the send non-blocking, and give the channel a buffer of one so that a trigger arriving while a fetch is in flight is latched rather than dropped; that fetch is fetching the previous repo, so we still need another one after it. The goEvery loop picks the trigger up as soon as it returns to its select, and concurrent triggers coalesce. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8633f4624..8b5e4b8d4 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -190,7 +190,11 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { // returns a channel that can be used to trigger the callback immediately func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { done := make(chan struct{}) - retrigger := make(chan struct{}) + // Buffered so that a retrigger arriving while the callback is running is + // latched rather than lost: the loop below doesn't receive again until the + // callback has finished, and the callback (a fetch) may be for the wrong + // repo if the retrigger came from a repo switch. + retrigger := make(chan struct{}, 1) go utils.Safe(func() { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -234,6 +238,16 @@ func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { func (self *BackgroundRoutineMgr) triggerImmediateFetch() { if self.triggerFetch != nil { - self.triggerFetch <- struct{}{} + // This runs on the UI thread, which must never block waiting for a + // background routine; in particular, the goEvery loop only receives + // between callbacks, and an in-flight fetch can itself be waiting for + // the UI thread to perform its post-fetch refresh, so a blocking send + // here would deadlock. The channel has a buffer of one, so the trigger + // is latched even when the loop isn't currently receiving; if one is + // already pending, the two coalesce. + select { + case self.triggerFetch <- struct{}{}: + default: + } } } From 3a0ba6bf4d33ce355237497b560a19bfd4a855ba Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:16:46 +0200 Subject: [PATCH 12/94] Fix data race on the triggerFetch field startBackgroundFetch assigned the field from its own goroutine, and only after the initial fetch had completed, while the UI thread reads it in triggerImmediateFetch on every repo switch, with no synchronization. Create the channel in startBackgroundRoutines instead, which runs on the UI thread before the fetch goroutine is spawned; everything the UI thread does afterwards is ordered after the write, so the read is race-free without any locking. To make this possible, goEvery now takes the retrigger channel as a parameter instead of creating and returning it; callers that have no use for a retrigger channel pass nil, and a nil channel in a select is simply never ready. As a side effect, a repo switch that happens before the fetch loop has started (during the intro popup or the initial fetch) now latches a trigger and causes an immediate fetch once the loop is running, where previously it was silently dropped. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8b5e4b8d4..f9eff420b 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -43,6 +43,11 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() { if userConfig.Git.AutoFetch { fetchInterval := userConfig.Refresher.FetchInterval if fetchInterval > 0 { + // The channel must be created here, on the UI thread and before + // the fetch goroutine spawns, so that triggerImmediateFetch (also + // running on the UI thread) can read the field without racing the + // write. See triggerImmediateFetch for why it is buffered. + self.triggerFetch = make(chan struct{}, 1) go utils.Safe(self.startBackgroundFetch) } else { self.gui.c.Log.Errorf( @@ -74,7 +79,7 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() { } if self.gui.Config.GetDebug() { - self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error { + self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, nil, func(_ bool) error { formatBytes := func(b uint64) string { const unit = 1000 if b < unit { @@ -125,14 +130,14 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { _ = fetch(true) userConfig := self.gui.UserConfig() - self.triggerFetch = self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, fetch) + self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, self.triggerFetch, fetch) } func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { self.gui.waitForIntro.Wait() userConfig := self.gui.UserConfig() - self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error { + self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, nil, func(_ bool) error { self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) return nil }) @@ -151,6 +156,7 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { self.goEvery( userConfig.Refresher.ExternalChangeCheckIntervalDuration(), self.gui.stopChan, + nil, func(_ bool) error { self.checkForExternalChanges() return nil @@ -187,14 +193,10 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) } -// returns a channel that can be used to trigger the callback immediately -func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { +// Runs function every interval until stop is closed. A send on retrigger (if +// non-nil) runs the callback immediately and restarts the interval. +func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigger chan struct{}, function func(bool) error) { done := make(chan struct{}) - // Buffered so that a retrigger arriving while the callback is running is - // latched rather than lost: the loop below doesn't receive again until the - // callback has finished, and the callback (a fetch) may be for the wrong - // repo if the retrigger came from a repo switch. - retrigger := make(chan struct{}, 1) go utils.Safe(func() { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -227,7 +229,6 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru } } }) - return retrigger } func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { From 76ad5a35521a6c972acf2d1802f5467e55bc5729 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 14:54:40 +0200 Subject: [PATCH 13/94] Clarify the contribution policy --- CONTRIBUTING.md | 277 ++++-------------------------------------------- 1 file changed, 19 insertions(+), 258 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd62a0eb7..49a9a625c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,273 +1,34 @@ # Contributing -This project does not accept pull requests. +## The short version -In todays world of agentic coding I have decided that it no longer makes sense for me to look at incoming pull requests. As far as I can tell, the vast majority of these is AI-generated these days, which in itself is not necessarily a bad thing; however, there's no way for me to tell whether the person posting the PR actually understands anything about the code that is being contributed or not, and I don't feel like spending time and energy on finding out whether they do. +This project does not accept pull requests. Don't bother making one, it won't be merged. -Now you might ask why this even matters; coding agents are capable of producing amazingly high-quality code, so why is it important that the person opening the PR understands it, as long as the code works and tests are green? It does actually matter very much to me. AI generated code needs to be carefully reviewed and iterated on, and it is the contributor's job to do that, not mine. And I have no idea to what extent the contributor has done this, or whether they are even capable of it. +However, there are other forms of contributions that are very welcome and encouraged; see below for what those are. -Every PR needs work and iterations until it is mergeable, whether manually coded or AI generated (even very good ones do), and if I don't know whether the person posting the PR will act on my review feedback themselves or just pass it on to their coding agent (which I guess is the much more likely case today), then it doesn't make sense for me to work with them. +## Why no PRs? -For this reason I will close incoming pull requests by default from now on, without comment. Sorry if this sounds hostile, but honestly I don't feel I have much of a choice if I want maintaining this project to still be enjoyable for me. +There are two main reasons for this, and I want to be very honest about them: -With that said, if you are indeed serious about contributing a high-quality PR to lazygit, and you are familiar with go, and you have learned enough about lazygit's code base to tell whether your changes are good, then do raise an issue and explain what you are planning to do, and somehow make it plausible that your PR will be worth my time reviewing it. In such a case I might make an exception from the default rule. +- I am maintaining lazygit for fun, as a hobby in my free time (which is quite limited). I'd like to spend my free time on things that I enjoy doing. I enjoy working on lazygit's code and improving it myself; I don't enjoy reviewing PRs. It's that simple, really. Reviewing PRs takes a lot of time; time that I would rather spend on developing lazygit myself. +- Even if I had the time and inclination to review PRs, this has become quite difficult today: most PRs nowadays are AI-generated to some extent (often completely), which in itself is not necessarily a bad thing; I heavily use AI myself these days, and I get great results from it. However, agentic coding needs to be guided by humans so that the results are good, and for contributed PRs I can't tell to what extent the human contributor did this, or is even capable of it; and I don't want to do the work of guiding a contributor's coding agent. If I post PR review feedback and have to suspect that the contributor simply passes it on to their coding agent, then that is a work mode that doesn't make sense to me, and I would rather just drive my own agent to do the work. -In the future I might also consider adopting a vouch system similar to [Ghostty's](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#first-time-contributors), but right now I feel the effort needed to set this up and maintain is not justified given the rather low number of high-quality contributions I have seen in recent times. +### Why it might still make sense to post a PR -Even though we no longer accept pull requests, I find it important to emphasize that Lazygit is still a community project, and non-PR contributions are still very welcome. Do file issues for bug reports or feature requests, and help shape the future of lazygit by actively participating in discussing UX designs. Also, the localization system very much depends on everybody's help with translating texts (see https://crowdin.com/project/lazygit). +I can think of two such reasons: ---- +- You implemented a lazygit improvement that you want to use yourself; in this case it could make sense to let others merge this change into their forks if they find it useful too. And if enough people say they want the feature, this can persuade me to add it, so putting it out there to give it visibility can be helpful. +- You posted an issue for a feature request, and have a prototype that implements it; it could be useful to publish the branch as a draft PR to better illustrate how the feature works. -The remainder of this document is the old version from a time when contributing pull requests was still encouraged. Keeping it here in case I reconsider my policy in the future. +For this reason I usually don't close pull requests to give them more visibility. Just don't expect your PR to be merged. -## PR walkthrough +## So how can I contribute then? -[This video](https://www.youtube.com/watch?v=kNavnhzZHtk) walks through the process of adding a small feature to lazygit. If you have no idea where to start, watching that video is a good first step. +There are other forms of contributions to a project besides source code that are very welcome and encouraged; for instance: -## Design principles +- File issues for bugs that you find, and I'll do my best to take care of fixing them (if they are important enough). +- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.) +- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios. +- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that. -See [here](./VISION.md) for a set of design principles that we want to consider when building a feature or making a change. - -## Codebase guide - -[This doc](./docs/dev/Codebase_Guide.md) explains: - -- what the different packages in the codebase are for -- where important files live -- important concepts in the code -- how the event loop works -- other useful information - -## All code changes happen through Pull Requests - -Pull requests are the best way to propose changes to the codebase. We actively -welcome your pull requests: - -1. Fork the repo and create your branch from `master`. -2. If you've added code that should be tested, add tests. -3. If you've added code that needs documentation, update the documentation. -4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). -5. Issue that pull request! - -Please do not raise pull request from your fork's master branch: make a feature branch instead. Lazygit maintainers will sometimes push changes to your branch when reviewing a PR and we often can't do this if you use your master branch. - -If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place. - -## Commit history - -We value a clean and useful commit history, so please take some time to organize your commits so that they make sense. Don't assume that they will be squashed on merge anyway; we don't do that here. - -In particular: - -- Refactorings and behavior changes should be in separate commits. There are very few exceptions where this is not possible, but in my experience they are very rare. -- Strive for minimal commits; every change that is independent from other changes should be in a commit of its own (with a good commit message that explains why the change is made). -- When you need to iterate over your implementation during review (e.g. because you discovered a bug, or a maintainer requested changes), don't just pile new commits on top. Use fixup commits to make your changes transparent while still maintaining a good commit history. If you don't know what that means, [here's a brief introduction](docs/Fixup_Commits.md). - -## A note about AI - -It has become common recently to throw an issue at a coding agent and submit whatever comes out of it as a PR. This is not appreciated here, and I will close PRs where I can tell this was the case, or where I even suspect it was the case. - -Some of these PRs may actually be good and useful, but many are not, and it's not a good use of my time as a maintainer to look at generated PRs to decide. This is the job of the PR's contributor, and if you don't speak enough go or can't be bothered to get familiar enough with lazygit's codebase to tell, then don't contribute the PR. - -## Running in a VSCode dev container - -If you want to spare yourself the hassle of setting up your dev environment yourself (i.e. installing Go, extensions, and extra tools), you can run the Lazygit code in a VSCode dev container like so: - -![image](https://user-images.githubusercontent.com/8456633/201500508-0d55f99f-5035-4a6f-a0f8-eaea5c003e5d.png) - -This requires that: - -- you have docker installed -- you have the dev containers extension installed in VSCode - -See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers. - -## Running in a Github Codespace - -If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace: - -![image](https://user-images.githubusercontent.com/8456633/201500566-ffe9105d-6030-4cc7-a525-6570b0b413a2.png) - -To run lazygit from within the integrated terminal just go `go run main.go` - -This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed. - -## Using Nix for development - -If you use Nix, you can leverage the included flake to set up a complete development environment with all necessary dependencies: - -```sh -nix develop -``` - -This will drop you into a development shell that includes: - -- Latest Go toolchain -- golangci-lint for code linting -- git and make - -You can also build and run lazygit using nix: - -```sh -# Build lazygit -nix build - -# Run lazygit directly -nix run -``` - -The nix flake supports multiple architectures (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and provides a consistent development environment across different systems. - -## Code of conduct - -Please note by participating in this project, you agree to abide by the [code of conduct]. - -[code of conduct]: https://github.com/jesseduffield/lazygit/blob/master/CODE-OF-CONDUCT.md - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be -under the same [MIT License](http://choosealicense.com/licenses/mit/) that -covers the project. Feel free to contact the maintainers if that's a concern. - -## Report bugs using Github's [issues](https://github.com/jesseduffield/lazygit/issues) - -We use GitHub issues to track public bugs. Report a bug by [opening a new -issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy! - -## Go - -This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently: - -1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it -2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface. -3. If a struct implements an interface, we make it explicit with something like: - -```go -var _ MyInterface = &MyStruct{} -``` - -This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing. - -### Code Formatting - -To check code formatting [gofumpt](https://pkg.go.dev/mvdan.cc/gofumpt#section-readme) (which is a bit stricter than [gofmt](https://pkg.go.dev/cmd/gofmt)) is used. -VSCode will format the code correctly if you tell the Go extension to use `gofumpt` via your [`settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) -by setting [`formatting.gofumpt`](https://github.com/golang/tools/blob/master/gopls/doc/settings.md#gofumpt-bool) to `true`: - -```jsonc -// .vscode/settings.json -{ - "gopls": { - "formatting.gofumpt": true - } -} -``` - -To run gofumpt from your terminal go: - -``` -go install mvdan.cc/gofumpt@latest && gofumpt -l -w . -``` - -## Programming Font - -Lazygit supports [Nerd Fonts](https://www.nerdfonts.com) to render certain icons. Sometimes we use some of these icons verbatim in string literals in the code (mainly in tests), so you need to set your development environment to use a nerd font to see these. - -## Internationalisation - -Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package. - -### For developers adding new text - -If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Then you can access via `gui.Tr.YourNewText` (or `self.c.Tr.YourNewText`, etc). - -Note, we use 'Sentence case' for everything (so no 'Title Case' or 'whatever-it's-called-when-there's-no-capital-letters-case') - -### For translators - -Lazygit translations are managed through [Crowdin](https://crowdin.com/project/lazygit/). If you'd like to contribute translations: - -1. Join the Crowdin project at https://crowdin.com/project/lazygit/ -2. Select your target language and help translate missing strings -3. The translation files in `pkg/i18n/translations/` are managed by the maintainers - please don't edit them directly - -For detailed information about the translation process, including how maintainers sync translations, see `pkg/i18n/translations/README.md`. - -## Debugging - -The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`). - -From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")` or `self.c.Log.Warn("blah")`. - -If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself. - -If you need to log from code in the vendor directory (e.g. the `gocui` package), you won't have access to the logger, but you can easily add logging support by setting the `LAZYGIT_LOG_PATH` environment variable and using `logs.Global.Warn("blah")`. This is a global logger that's only intended for development purposes. - -If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy! - -### VSCode debugger - -If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the `console` key is, at the time of writing, still an experimental feature. - -```jsonc -// .vscode/launch.json -{ - "version": "0.2.0", - "configurations": [ - { - "name": "debug lazygit", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "main.go", - "args": ["--debug"], - "console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging - } - ] -} -``` - -## Profiling - -If you want to investigate what's contributing to CPU or memory usage, see [this separate document](docs/dev/Profiling.md). - -## Testing - -Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. For integration tests, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) - -## Updating Gocui - -Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow: - -1. Make the changes in gocui inside lazygit's vendor directory so it's easy to test against lazygit -2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`) -3. Raise a PR on the gocui repo with your changes -4. After that PR is merged, make a PR in lazygit bumping the gocui version. You can bump the version by running the following at the lazygit repo root: - -```sh -./scripts/bump_gocui.sh -``` - -5. Raise a PR in lazygit with those changes - -## Updating Lazycore - -[Lazycore](https://github.com/jesseduffield/lazycore) is a repo containing shared functionality between lazygit and lazydocker. Sometimes you will need to make a change to that repo and import the changes into lazygit. Similar to updating Gocui, here's what you do: - -1. Make the changes in lazycore inside lazygit's vendor directory so it's easy to test against lazygit -2. Copy the changes over to the actual lazycore repo (clone it if you haven't already, and use the `master` branch) -3. Raise a PR on the lazycore repo with your changes -4. After that PR is merged, make a PR in lazygit bumping the lazycore version. You can bump the version by running the following at the lazygit repo root: - -```sh -./scripts/bump_lazycore.sh -``` - -Or if you're using VSCode, there is a bump lazycore task you can find by going `cmd+shift+p` and typing 'Run task' - -5. Raise a PR in lazygit with those changes - -## Improvements - -If you can think of any way to improve these docs let us know. +Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate. From 50122e6886855a01f33a1ccb3e8b7d6de1f624d8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 14:54:56 +0200 Subject: [PATCH 14/94] Don't invite for contributions at startup --- pkg/i18n/english.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 69ea7012f..22b05e6c0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1130,12 +1130,7 @@ Thanks for using lazygit! Seriously you rock. Three things to share with you: 2) Be sure to read the latest release notes at: https://github.com/jesseduffield/lazygit/releases - 3) If you're using git, that makes you a programmer! With your help we can make - lazygit better, so consider becoming a contributor and joining the fun at - https://github.com/jesseduffield/lazygit - Or even just star the repo to share the love! - - 4) If lazygit has made your life easier, you can say thanks by clicking the + 3) If lazygit has made your life easier, you can say thanks by clicking the donate button at the bottom right. Donation does not grant priority support, but it is much appreciated. From e90daaf81287b4ab5d53166d69651d9aba75ec9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:06 +0200 Subject: [PATCH 15/94] Unify the git command lock-retry loops RunWithOutput and RunWithOutputs each carried their own near-identical copy of the index.lock retry loop. Extract the loop into a single retryOnLockError helper so the retry policy lives in one place, ahead of changing that policy. Behavior is unchanged; the added tests characterize it (success and non-lock errors run once, a lock error in the output is retried). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 42 +++++------ pkg/commands/git_cmd_obj_runner_test.go | 92 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 pkg/commands/git_cmd_obj_runner_test.go diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index 668feef93..a72565b5c 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -33,11 +33,30 @@ func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { } func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) { + return self.retryOnLockError(func() (string, error) { + return self.innerRunner.RunWithOutput(cmdObj.Clone()) + }) +} + +func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) { + var stdout, stderr string + _, err := self.retryOnLockError(func() (string, error) { + var runErr error + stdout, stderr, runErr = self.innerRunner.RunWithOutputs(cmdObj.Clone()) + return stdout + stderr, runErr + }) + return stdout, stderr, err +} + +// retryOnLockError runs the given function, retrying if it fails with a +// transient lock error (see isRetryableError). The string returned by run is +// the command output we inspect to classify the failure. We clone the command +// for each attempt (inside run) because an *exec.Cmd can only be run once. +func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) { var output string var err error for range RetryCount { - newCmdObj := cmdObj.Clone() - output, err = self.innerRunner.RunWithOutput(newCmdObj) + output, err = run() if err == nil || !isRetryableError(output) { return output, err @@ -51,25 +70,6 @@ func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, e return output, err } -func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) { - var stdout, stderr string - var err error - for range RetryCount { - newCmdObj := cmdObj.Clone() - stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj) - - if err == nil || !isRetryableError(stdout+stderr) { - return stdout, stderr, err - } - - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) - } - - return stdout, stderr, err -} - // Retry logic not implemented here, but these commands typically don't need to obtain a lock. func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj *oscommands.CmdObj, onLine func(line string) (bool, error)) error { return self.innerRunner.RunAndProcessLines(cmdObj, onLine) diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go new file mode 100644 index 000000000..e8c73727d --- /dev/null +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -0,0 +1,92 @@ +package commands + +import ( + "errors" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +type runnerResult struct { + output string + err error +} + +// scriptedRunner is an ICmdObjRunner stub that returns a preconfigured result +// for each successive call, letting us drive the retry loop deterministically. +// It counts calls so tests can assert whether a command was retried. +type scriptedRunner struct { + results []runnerResult + calls int +} + +func (self *scriptedRunner) next() (string, error) { + result := self.results[self.calls] + self.calls++ + return result.output, result.err +} + +func (self *scriptedRunner) Run(*oscommands.CmdObj) error { + _, err := self.next() + return err +} + +func (self *scriptedRunner) RunWithOutput(*oscommands.CmdObj) (string, error) { + return self.next() +} + +func (self *scriptedRunner) RunWithOutputs(*oscommands.CmdObj) (string, string, error) { + output, err := self.next() + return output, "", err +} + +func (self *scriptedRunner) RunAndProcessLines(*oscommands.CmdObj, func(string) (bool, error)) error { + panic("not implemented") +} + +func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner { + return &gitCmdObjRunner{ + log: utils.NewDummyLog(), + innerRunner: inner, + } +} + +// dummyCmdObj returns a throwaway command; only its clonability matters, since +// the scriptedRunner ignores it and returns preconfigured results. +func dummyCmdObj() *oscommands.CmdObj { + return oscommands.NewDummyCmdObjBuilder(nil).New([]string{"git", "status"}) +} + +func TestRunWithOutputReturnsSuccessWithoutRetrying(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "done", err: nil}}} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputDoesNotRetryNonLockError(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "boom", err: errors.New("boom")}}} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{ + {output: "fatal: Unable to create '/repo/.git/index.lock': File exists.", err: errors.New("exit status 128")}, + {output: "done", err: nil}, + }} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 2, inner.calls) +} From 0902c5c05878b97c17611cec804a99562694372f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:26 +0200 Subject: [PATCH 16/94] Demonstrate that a lock error in a streamed command isn't retried The gpg helper runs commands like amend with StreamOutput, so their output isn't captured and a failed run returns an empty output string; the index.lock message is carried by the error instead. isRetryableError only inspects the output, so the retry loop never fires for these commands. In practice this means a `shift-A` amend issued while a foreground `git status` refresh briefly holds index.lock fails outright instead of retrying. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index e8c73727d..2ff2795dc 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -90,3 +90,22 @@ func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) { assert.Equal(t, "done", output) assert.Equal(t, 2, inner.calls) } + +func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { + // A streamed command (e.g. an amend run through the gpg helper) doesn't + // capture its output, so a lock failure surfaces only in the returned error + // with an empty output string. The retry logic must still recognize it. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + /* EXPECTED: + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) + ACTUAL: */ + assert.Error(t, err) + assert.Equal(t, 1, inner.calls) +} From c1cd500fa776c54f61b4cf6dddf6de0d90c7aa1a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:56 +0200 Subject: [PATCH 17/94] Retry lock errors reported only through the command's error Have isRetryableError also inspect the returned error, not just the captured output. Streamed commands (amend, commit, and other operations run through the gpg helper) don't capture output, so their index.lock failures were slipping past the retry loop and surfacing to the user as a hard "Git command failed". Now they retry like every other command. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 18 ++++++++++++------ pkg/commands/git_cmd_obj_runner_test.go | 4 ---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index a72565b5c..f22a0f7aa 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -20,11 +20,17 @@ type gitCmdObjRunner struct { innerRunner oscommands.ICmdObjRunner } -// isRetryableError returns true if the error output indicates a transient -// lock-related error that may succeed on retry -func isRetryableError(output string) bool { - return strings.Contains(output, ".git/index.lock") || - strings.Contains(output, "cannot lock ref") +// isRetryableError returns true if a failed command hit a transient +// lock-related condition that may succeed on retry. The lock message can reach +// us either in the command's captured output or, for streamed commands whose +// output we don't capture, only in the returned error, so we check both. +func isRetryableError(output string, err error) bool { + text := output + if err != nil { + text += "\n" + err.Error() + } + return strings.Contains(text, ".git/index.lock") || + strings.Contains(text, "cannot lock ref") } func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { @@ -58,7 +64,7 @@ func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (strin for range RetryCount { output, err = run() - if err == nil || !isRetryableError(output) { + if err == nil || !isRetryableError(output, err) { return output, err } diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 2ff2795dc..903c6a90b 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -102,10 +102,6 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) - /* EXPECTED: assert.NoError(t, err) assert.Equal(t, 2, inner.calls) - ACTUAL: */ - assert.Error(t, err) - assert.Equal(t, 1, inner.calls) } From e3ecb77939e191281ef3a70509be1183acc2b41e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:57:26 +0200 Subject: [PATCH 18/94] Recognize index.lock contention in worktrees and submodules The retry check matched the literal ".git/index.lock", which only ever appears for the main worktree. A linked worktree's lock is at .git/worktrees//index.lock and a submodule's is under its own git dir, so contention there was never retried. Match the bare "index.lock" fragment instead, which covers all of them. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 7 ++++++- pkg/commands/git_cmd_obj_runner_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index f22a0f7aa..c4d2ec049 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -24,12 +24,17 @@ type gitCmdObjRunner struct { // lock-related condition that may succeed on retry. The lock message can reach // us either in the command's captured output or, for streamed commands whose // output we don't capture, only in the returned error, so we check both. +// +// We match the bare "index.lock" fragment rather than a fuller path or message +// so we catch the lock wherever git puts it: the main .git dir, a linked +// worktree's git dir (.git/worktrees//index.lock), or a submodule's git +// dir. func isRetryableError(output string, err error) bool { text := output if err != nil { text += "\n" + err.Error() } - return strings.Contains(text, ".git/index.lock") || + return strings.Contains(text, "index.lock") || strings.Contains(text, "cannot lock ref") } diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 903c6a90b..2cde503f5 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -105,3 +105,18 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, inner.calls) } + +func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) { + // In a linked worktree the lock lives at .git/worktrees//index.lock + // rather than .git/index.lock, so only matching the bare "index.lock" + // fragment lets the retry fire there too. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/worktrees/feature/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) +} From 4052057eee1073f2afe7c5cc1e3027995167c642 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:58:22 +0200 Subject: [PATCH 19/94] Back off exponentially between lock-error retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry budget was five fixed 50ms waits (250ms total). A foreground `git status` refresh can hold index.lock for longer than that on a large repo, so the retries could be exhausted before the lock clears. Wait 20ms before the first retry and double each time, giving seven attempts over a bit more than a second — enough to outlast a slow refresh while keeping the common case (a lock that clears almost immediately) fast. The initial delay is now a runner field so tests can zero it out instead of sleeping. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_builder.go | 5 +++-- pkg/commands/git_cmd_obj_runner.go | 28 ++++++++++++++++++------- pkg/commands/git_cmd_obj_runner_test.go | 15 +++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 6bc3b7d19..20d31c11c 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -25,8 +25,9 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ - log: log, - innerRunner: runner, + log: log, + innerRunner: runner, + initialRetryDelay: defaultInitialRetryDelay, } }) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index c4d2ec049..8112b0f30 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -11,13 +11,24 @@ import ( // here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock const ( - WaitTime = 50 * time.Millisecond - RetryCount = 5 + // defaultInitialRetryDelay is how long we wait before the first retry of a + // command that failed with a transient lock error. We double it before each + // subsequent retry (see retryOnLockError), so across maxRetries attempts we + // wait for a bit over a second in total. That's long enough to outlast the + // brief window during which another git process holds a lock we need — + // typically our own foreground `git status` refresh, which takes index.lock + // to persist its refreshed stat-cache. + defaultInitialRetryDelay = 20 * time.Millisecond + maxRetries = 7 ) type gitCmdObjRunner struct { log *logrus.Entry innerRunner oscommands.ICmdObjRunner + // initialRetryDelay is the wait before the first lock-error retry. It's a + // field rather than the constant directly so tests can set it to zero and + // not actually sleep. + initialRetryDelay time.Duration } // isRetryableError returns true if a failed command hit a transient @@ -64,18 +75,21 @@ func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, // the command output we inspect to classify the failure. We clone the command // for each attempt (inside run) because an *exec.Cmd can only be run once. func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) { + delay := self.initialRetryDelay var output string var err error - for range RetryCount { + for attempt := range maxRetries { output, err = run() if err == nil || !isRetryableError(output, err) { - return output, err + break } - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) + if attempt < maxRetries-1 { + self.log.Warnf("lock error prevented command from running; retrying in %s", delay) + time.Sleep(delay) + delay *= 2 + } } return output, err diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 2cde503f5..bf938da54 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -50,6 +50,8 @@ func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner { return &gitCmdObjRunner{ log: utils.NewDummyLog(), innerRunner: inner, + // don't actually sleep between retries + initialRetryDelay: 0, } } @@ -106,6 +108,19 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { assert.Equal(t, 2, inner.calls) } +func TestRunWithOutputGivesUpAfterMaxRetries(t *testing.T) { + results := make([]runnerResult, maxRetries) + for i := range results { + results[i] = runnerResult{err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")} + } + inner := &scriptedRunner{results: results} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, maxRetries, inner.calls) +} + func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) { // In a linked worktree the lock lives at .git/worktrees//index.lock // rather than .git/index.lock, so only matching the bare "index.lock" From 49eefbcf379935999132041f3e24d4b60f6e1d7d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 09:35:24 +0200 Subject: [PATCH 20/94] Make the user-event queue unbounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update and friends enqueued onto a fixed 256-slot channel with a non-blocking send that panicked when the channel was full. That guard was firing in real use: - Toggling a directory of several hundred files into a custom patch (reliably): the operation runs on a worker behind a waiting status, whose spinner enqueues a content-only render on every tick, and over the long operation these outrun the UI loop and overflow the buffer. - Editing the config in an editor that suspends lazygit: the editor subprocess runs on the UI thread, so the loop drains nothing for the whole editing session, and the full refresh fired on resume fans out across every scope at once — a burst of updates that overflows before the just-resumed loop catches up. - Any time the UI thread blocks for a long time, the periodic refreshes keep enqueuing and eventually overflow. The 256-slot buffer was chosen deliberately, with the panic as a "should never happen" guard, to preserve two properties: FIFO ordering of same-goroutine Update calls (an earlier goroutine-per-Update design reordered them), and no self-deadlock (a blocking send from the UI thread would block against the loop that drains it). But a fixed channel can only offer those by crashing on overflow. Replace it with an unbounded, order-preserving queue: a mutex-guarded slice plus a buffered(1) doorbell channel that wakes the main loop's select. Enqueuing appends and rings the doorbell; the loop drains the slice to empty on each wake. This keeps FIFO order and never blocks the caller, so there is no self-deadlock and no overflow to panic on — under a stall the queue just grows and then drains. This also removes an inconsistency: updateContentOnly did a plain blocking send while update panicked, so the two paths disagreed on what happened when the queue was full. Both now share the same enqueue. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/flush_test.go | 10 +-- pkg/gocui/gui.go | 111 ++++++++++++++++++++++------- pkg/gocui/user_event_queue_test.go | 80 +++++++++++++++++++++ 3 files changed, 171 insertions(+), 30 deletions(-) create mode 100644 pkg/gocui/user_event_queue_test.go diff --git a/pkg/gocui/flush_test.go b/pkg/gocui/flush_test.go index 59bae427c..d4082fcf6 100644 --- a/pkg/gocui/flush_test.go +++ b/pkg/gocui/flush_test.go @@ -39,15 +39,15 @@ func setupViews(t *testing.T, g *Gui) (*View, *View) { return status, main } -// pushContentOnly pushes a content-only event directly to the channel -// (synchronous, deterministic — unlike Update which spawns a goroutine). +// pushContentOnly enqueues a content-only event directly, letting the test +// control the contentOnly flag (which Update/UpdateContentOnly hard-code). func pushContentOnly(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: true}) } -// pushRegular pushes a regular event directly to the channel. +// pushRegular enqueues a regular (non-content-only) event directly. func pushRegular(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: false}) } func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) { diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ee1995911..103c90483 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -131,7 +131,7 @@ type Gui struct { viewMouseBindings []*ViewMouseBinding lastClick *clickInfo gEvents chan GocuiEvent - userEvents chan userEvent + userEvents *userEventQueue views []*View currentView *View managers []Manager @@ -238,12 +238,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.stop = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) - // Update does a non-blocking send and panics on a full channel rather than - // blocking (which would deadlock the UI goroutine against itself) or - // silently reordering. The buffer is sized well above the peak occupancy we - // see in practice, so the panic stays unreachable in normal use; if it ever - // fires, that's a real anomaly to investigate, not a cue to grow the buffer. - g.userEvents = make(chan userEvent, 256) + g.userEvents = newUserEventQueue() g.taskManager = newTaskManager() if opts.PlayRecording { @@ -618,29 +613,83 @@ type userEvent struct { contentOnly bool } -// Update enqueues f on the user-events channel for the UI loop to run on its -// next iteration. Multiple Update calls from the same goroutine arrive in -// source order via the channel's FIFO. The send is non-blocking — if the -// channel is full we panic rather than block or silently reorder, since a -// blocked send from the UI goroutine would deadlock against itself and -// silently switching to inline execution would break the ordering guarantee -// callers rely on. The buffer is sized generously enough that this should -// never fire in practice; if it does, that's a signal to investigate, not -// to grow the buffer reflexively. -func (g *Gui) Update(f func(*Gui) error) { - task := g.NewTask() +// userEventQueue is an unbounded, order-preserving FIFO of work enqueued by +// Update and friends for the main loop to run. +// +// It's unbounded (rather than a fixed-size channel) because producers must +// never block or lose work. Update can be called from the UI goroutine itself, +// where a blocking send would deadlock against the loop that drains the queue; +// and it can be called from arbitrary worker goroutines that may enqueue faster +// than the loop drains. That happens while the loop is stalled — suspended for +// a subprocess (the editor runs on the UI thread), or hung in a long handler — +// and also when a long-running worker operation emits a steady stream of +// updates that outpaces the loop (e.g. the waiting-status spinner ticks while a +// large directory is toggled into a custom patch). A fixed channel forces a +// choice between blocking (deadlock), dropping or reordering, and panicking on +// overflow; an unbounded queue avoids all three while preserving FIFO order. +// +// enqueue appends under the mutex and rings the doorbell; the main loop selects +// on the doorbell to wake, then drains the slice to empty. The doorbell is +// buffered(1) and rung with a non-blocking send, so it's a coalescing "work +// pending" flag rather than a per-event signal: a burst of appends leaves at +// most one token, and the loop drains everything the token represents on a +// single wake. A token left over after a drain (because the drain happened to +// empty the slice after the ring) just causes one harmless empty wake. +type userEventQueue struct { + mutex sync.Mutex + events []userEvent + doorbell chan struct{} +} + +func newUserEventQueue() *userEventQueue { + return &userEventQueue{doorbell: make(chan struct{}, 1)} +} + +// enqueue appends an event and wakes the main loop. It never blocks. +func (q *userEventQueue) enqueue(ev userEvent) { + q.mutex.Lock() + q.events = append(q.events, ev) + q.mutex.Unlock() select { - case g.userEvents <- userEvent{f: f, task: task}: + case q.doorbell <- struct{}{}: default: - panic("gocui: userEvents channel full; refusing to block or reorder") } } +// dequeue pops the oldest event, reporting false when the queue is empty. +func (q *userEventQueue) dequeue() (userEvent, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + + if len(q.events) == 0 { + return userEvent{}, false + } + ev := q.events[0] + if len(q.events) == 1 { + // Release the backing array whenever the queue drains, so a one-off + // burst doesn't pin its peak size for the rest of the session. + q.events = nil + } else { + q.events[0] = userEvent{} + q.events = q.events[1:] + } + return ev, true +} + +// Update enqueues f for the UI loop to run on its next iteration. Multiple +// Update calls from the same goroutine arrive in source order (the queue is +// FIFO). The enqueue never blocks and never drops work; see userEventQueue for +// why the queue is unbounded. +func (g *Gui) Update(f func(*Gui) error) { + task := g.NewTask() + g.userEvents.enqueue(userEvent{f: f, task: task}) +} + // Like Update, but signals that the callback only modifies content. func (g *Gui) UpdateContentOnly(f func(*Gui) error) { task := g.NewTask() - g.userEvents <- userEvent{f: f, task: task, contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true}) } // Calls a function in a goroutine. Handles panics gracefully and tracks @@ -766,7 +815,14 @@ func (g *Gui) processEvent() error { if err := g.handleError(g.handleEvent(&ev)); err != nil { return err } - case ev := <-g.userEvents: + case <-g.userEvents.doorbell: + ev, ok := g.userEvents.dequeue() + if !ok { + // A leftover doorbell token whose events were already drained by a + // previous iteration's processRemainingEvents: nothing to run and + // nothing new to render. + return nil + } contentOnly = ev.contentOnly defer func() { ev.task.Done() }() @@ -798,15 +854,20 @@ func (g *Gui) processRemainingEvents() (bool, error) { if err := g.handleError(g.handleEvent(&ev)); err != nil { return false, err } - case ev := <-g.userEvents: + default: + // No gui event is pending; drain a queued user event instead. + // gui events take priority so input stays responsive, but they're + // bounded (buffer of 20), so this can't starve the user-event queue. + ev, ok := g.userEvents.dequeue() + if !ok { + return contentOnly, nil + } contentOnly = ev.contentOnly && contentOnly err := g.handleError(ev.f(g)) ev.task.Done() if err != nil { return false, err } - default: - return contentOnly, nil } } } diff --git a/pkg/gocui/user_event_queue_test.go b/pkg/gocui/user_event_queue_test.go new file mode 100644 index 000000000..10e86eef8 --- /dev/null +++ b/pkg/gocui/user_event_queue_test.go @@ -0,0 +1,80 @@ +package gocui + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Enqueuing far more events than the old fixed 256-slot buffer, without the +// main loop draining them, used to panic ("userEvents channel full"). It must +// not: producers can legitimately burst faster than a stalled UI loop drains +// (e.g. one command-log entry per git command when adding a large directory to +// a custom patch, or any producer while the loop is blocked in a subprocess). +// The events must also stay in FIFO order. +func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) { + g := newTestGui(t) + + const n = 1000 + var got []int + for i := range n { + g.Update(func(*Gui) error { + got = append(got, i) + return nil + }) + } + + // Drain the whole queue the way the main loop's inner drain does. + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + want := make([]int, n) + for i := range want { + want[i] = i + } + assert.Equal(t, want, got) +} + +// Concurrent producers must be able to enqueue safely (run under -race). Only +// same-goroutine order is guaranteed, so we check that every event is delivered +// exactly once and that each producer's own events stay in order. +func TestUpdateConcurrentProducers(t *testing.T) { + g := newTestGui(t) + + const producers = 8 + const perProducer = 500 + + type item struct{ producer, seq int } + var got []item + + var wg sync.WaitGroup + for p := range producers { + wg.Add(1) + go func() { + defer wg.Done() + for seq := range perProducer { + g.Update(func(*Gui) error { + got = append(got, item{p, seq}) + return nil + }) + } + }() + } + // Update is a synchronous, non-blocking enqueue, so once every producer has + // returned, every event is in the queue and a single drain sees them all. + wg.Wait() + + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + assert.Len(t, got, producers*perProducer) + lastSeq := make([]int, producers) + for p := range lastSeq { + lastSeq[p] = -1 + } + for _, it := range got { + assert.Equal(t, lastSeq[it.producer]+1, it.seq, "producer %d events out of order", it.producer) + lastSeq[it.producer] = it.seq + } +} From f0b139f3ab42f833b42792c10fd63f361de04a76 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:50:57 +0200 Subject: [PATCH 21/94] Log the user-event queue's high-water mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the queue is unbounded, its depth is a useful signal for understanding how the event loop behaves under load — and we expect it to look very different across builds (e.g. master, which carries the bounce-state-updates-to-ui-thread work, versus the v0.63.0 release this fix ships in). Track the deepest the queue has ever been and log an Info line whenever that record is broken, so the numbers show up in the log for later reasoning. The mark is session-wide and doesn't reset when the queue drains. gocui has no logger of its own, so it exposes the new depth through a handler (matching the existing SetFocusHandler / SetOpenHyperlinkFunc pattern) that the gui registers to log via its own logger. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 32 ++++++++++++++++++++++++++++++ pkg/gocui/user_event_queue_test.go | 31 +++++++++++++++++++++++++++++ pkg/gui/gui.go | 4 ++++ 3 files changed, 67 insertions(+) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 103c90483..bb9fc9874 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -603,6 +603,13 @@ func (g *Gui) SetRenderSearchStatusFunc(renderSearchStatusFunc func(*View, int, g.renderSearchStatusFunc = renderSearchStatusFunc } +// SetUpdateQueueHighWaterMarkHandler registers a diagnostic callback invoked +// with the new depth whenever the queue of pending Update callbacks reaches a +// new maximum. It may be called from any goroutine. +func (g *Gui) SetUpdateQueueHighWaterMarkHandler(f func(depth int)) { + g.userEvents.setHighWaterMarkHandler(f) +} + // userEvent represents an event triggered by the user. type userEvent struct { f func(*Gui) error @@ -639,6 +646,13 @@ type userEventQueue struct { mutex sync.Mutex events []userEvent doorbell chan struct{} + + // highWaterMark is the deepest the queue has ever been, and + // onHighWaterMark (if set) is called with the new depth each time that + // record is broken. Purely diagnostic: it lets us see how deep the queue + // gets in practice (see SetUpdateQueueHighWaterMarkHandler). + highWaterMark int + onHighWaterMark func(int) } func newUserEventQueue() *userEventQueue { @@ -649,14 +663,32 @@ func newUserEventQueue() *userEventQueue { func (q *userEventQueue) enqueue(ev userEvent) { q.mutex.Lock() q.events = append(q.events, ev) + newHighWaterMark := 0 + if len(q.events) > q.highWaterMark { + q.highWaterMark = len(q.events) + newHighWaterMark = q.highWaterMark + } + onHighWaterMark := q.onHighWaterMark q.mutex.Unlock() + // Report outside the lock: the handler does I/O (logging) and must not + // stall other producers or the draining loop. + if newHighWaterMark > 0 && onHighWaterMark != nil { + onHighWaterMark(newHighWaterMark) + } + select { case q.doorbell <- struct{}{}: default: } } +func (q *userEventQueue) setHighWaterMarkHandler(f func(int)) { + q.mutex.Lock() + q.onHighWaterMark = f + q.mutex.Unlock() +} + // dequeue pops the oldest event, reporting false when the queue is empty. func (q *userEventQueue) dequeue() (userEvent, bool) { q.mutex.Lock() diff --git a/pkg/gocui/user_event_queue_test.go b/pkg/gocui/user_event_queue_test.go index 10e86eef8..e547debb4 100644 --- a/pkg/gocui/user_event_queue_test.go +++ b/pkg/gocui/user_event_queue_test.go @@ -36,6 +36,37 @@ func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) { assert.Equal(t, want, got) } +// The high-water-mark handler fires only when the queue reaches a new maximum +// depth, reporting that depth. It does not reset when the queue drains. +func TestUpdateQueueHighWaterMark(t *testing.T) { + g := newTestGui(t) + + var marks []int + g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { marks = append(marks, depth) }) + + noop := func(*Gui) error { return nil } + + // Three enqueues with no drain: new highs 1, 2, 3. + g.Update(noop) + g.Update(noop) + g.Update(noop) + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + // Two enqueues stay below the previous high of 3: no new marks. + g.Update(noop) + g.Update(noop) + _, err = g.processRemainingEvents() + assert.NoError(t, err) + + // Four enqueues with no drain: only depth 4 beats the previous high. + for range 4 { + g.Update(noop) + } + + assert.Equal(t, []int{1, 2, 3, 4}, marks) +} + // Concurrent producers must be able to enqueue safely (run under -race). Only // same-goroutine order is guaranteed, so we check that every event is delivered // exactly once and that each producer's own events stay in order. diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e23afd124..61d4a90e2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -414,6 +414,10 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context return nil }) + gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { + gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth) + }) + gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) { ctx, ok := gui.helpers.View.ContextForView(v.Name()) if ok { From f116874f0a8c243642ee7ea3ae5caffa1e2ec782 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:07:15 +0200 Subject: [PATCH 22/94] Fix a deadlock when a Windows pty task is stopped mid-output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winPty.Close could block indefinitely, and it is called while holding the global PtyMutex and while the task's onDone sync.Once is executing, so blocking there wedges the task's entire cleanup chain: the next NewTask call blocks on <-notifyStopped while holding waitingMutex, every later task for that view queues up behind it, and onResize blocks on PtyMutex — a full UI freeze. (Reported by a user via go-deadlock's 30s watchdog; a regression from the ConPTY support introduced for v0.63.0.) ClosePseudoConsole is what blocks; before Windows 11 24H2 it can do so in two ways. It flushes the client's pending output into the out pipe, but a stopped task's scanner goroutine has already quit draining, so with a client that's still producing output the flush never completes; this can also wedge the background waiter's closeHpc, which runs with the pipes deliberately left open. And it waits for the console host to exit, but closing only delivers CTRL_CLOSE_EVENT to the attached client without terminating it, so a client that keeps running (git still computing an expensive diff, a pager waiting for input) keeps the host alive arbitrarily long. Run the teardown on a background goroutine so Close returns immediately no matter which of these strikes, and within it close our pipe ends before the pseudoconsole, without taking p.mu: breaking the pipes fails a pending flush fast, which also unblocks a waiter already stuck in one. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 53 +++++++++++++++----------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 0a4a06477..72ade5110 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -7,6 +7,7 @@ import ( "sync" "unsafe" + "github.com/jesseduffield/lazygit/pkg/utils" "golang.org/x/sys/windows" ) @@ -15,15 +16,13 @@ type winPty struct { inWrite *os.File outRead *os.File - // mu guards the teardown state below and serializes it against Resize. - // hpcClosed gates ClosePseudoConsole (it must run exactly once) and also - // keeps Resize from touching the HPCON once it's been freed: the - // background waiter in StartPty closes the pseudoconsole on child exit, - // which would otherwise race a concurrent onResize and hand - // ResizePseudoConsole a freed handle. + // mu guards hpcClosed, which gates ClosePseudoConsole (it must run + // exactly once) and also keeps Resize from touching the HPCON once it's + // been freed: the background waiter in StartPty closes the pseudoconsole + // on child exit, which would otherwise race a concurrent onResize and + // hand ResizePseudoConsole a freed handle. mu sync.Mutex hpcClosed bool - closed bool } func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) } @@ -49,11 +48,6 @@ func (p *winPty) Resize(cols, rows uint16) error { func (p *winPty) closeHpc() { p.mu.Lock() defer p.mu.Unlock() - p.closeHpcLocked() -} - -// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu. -func (p *winPty) closeHpcLocked() { if p.hpcClosed { return } @@ -61,18 +55,31 @@ func (p *winPty) closeHpcLocked() { windows.ClosePseudoConsole(p.hpc) } +// Close tears the pty down without waiting for it: the teardown runs on a +// background goroutine and Close returns immediately. +// +// It has to, because ClosePseudoConsole can block for a long time: before +// Windows 11 24H2 it waits for the console host to exit, and since closing +// only delivers CTRL_CLOSE_EVENT to the attached client without terminating +// it, a client that keeps running (git still computing an expensive diff, a +// pager waiting for input) keeps the host — and with it ClosePseudoConsole — +// alive arbitrarily long. Close is called while holding the global PtyMutex +// and while the task's onDone once is executing, where blocking wedges every +// subsequent task for the view (and with it the UI), so none of this may +// happen on the caller's thread. +// +// Within the teardown, the pipe ends must be closed before the +// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes +// the client's pending output into the out pipe, and with the task stopped +// nobody is reading anymore, so that flush can only complete once the pipe +// is broken. The background waiter's closeHpc may already be wedged in such +// a flush while holding p.mu; closing the pipes is what unblocks it. func (p *winPty) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed { - return nil - } - p.closed = true - // Closing the pseudoconsole breaks the pipes; the child's next write - // fails and it exits. Then we close our ends of the pipes. - p.closeHpcLocked() - p.inWrite.Close() - p.outRead.Close() + go utils.Safe(func() { + p.inWrite.Close() + p.outRead.Close() + p.closeHpc() + }) return nil } From a65d468cd3812b98c0acb2baf8ad3e548911be40 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:56:56 +0200 Subject: [PATCH 23/94] Determine the latest tag from the checked-out commit's history The Get Latest Tag step used to pick the most recently created tag in the entire repo, regardless of whether it is reachable from the commit being released. In preparation for supporting releases from branches other than master, use the nearest tag that is an ancestor of the checked-out commit instead. This way, a patch release cut from an older release branch bumps that branch's own latest tag even when master already carries a newer release, and the "changes since last release" check compares against the release that actually precedes this one in history. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b9815d44..84636424b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: - name: Get Latest Tag run: | - latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0") + latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0") if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: Tag format is invalid. Expected format: vX.X.X" From dda0af0f483b34c7105d64dceca0b088c2e7e55f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 13:16:21 +0200 Subject: [PATCH 24/94] Allow having branch and tag with the same name When creating a patch release from a branch called `v0.63.1`, the new tag would get the same name and pushing it would fail with `error: src refspec v0.63.1 matches more than one`. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84636424b..a3a5f62c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,7 +151,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$NEW_TAG" -a -m "Release $NEW_TAG" - git push origin "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" - name: Setup Go uses: actions/setup-go@v6 From 1d99ba56fc768ae64ffc125c1f5d300060eed860 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:57:22 +0200 Subject: [PATCH 25/94] Allow releasing from a branch other than master This is useful for cutting a patch release for the previous version when master already contains work that shouldn't be released yet; for example, v0.63.1 had to be tagged and released by hand from a v0.63.1 branch off the v0.63.0 tag because the workflow could only release master. Scheduled runs are unaffected: with no input provided, the ref is empty and the checkout falls back to the default branch. --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3a5f62c2..1ef271d6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ on: options: - minor - patch + branch: + description: 'Branch to release from' + type: string + required: true + default: 'master' ignore_blocks: description: 'Ignore blocking PRs/issues' type: boolean @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@v7 with: repository: jesseduffield/lazygit + ref: ${{ inputs.branch }} token: ${{ secrets.LAZYGIT_RELEASE_PAT }} fetch-depth: 0 From bd8c06ddc0910bccd6522131a2ad9c0c7c143396 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 11:01:56 +0200 Subject: [PATCH 26/94] Rename version_bump options to be extra clear I keep getting slightly confused as to which is which, so make this extra clear. While at it, change the default to minor, this is the option that is more often used now that we don't have regular scheduled releases any more. --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ef271d6f..e3cf63bbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,10 @@ on: description: 'Version bump type' type: choice required: true - default: 'patch' + default: 'minor (normal)' options: - - minor - - patch + - minor (normal) + - patch (hotfix) branch: description: 'Branch to release from' type: string @@ -127,7 +127,7 @@ jobs: IFS='.' read -r major minor patch <<< "$LATEST_TAG" if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$VERSION_BUMP" == "patch" ]]; then + if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then patch=$((patch + 1)) else minor=$((minor + 1)) From 7e1073a0ee1d8338bb1c3d39d3cbbd7eedbdacee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:32:48 +0200 Subject: [PATCH 27/94] Extract the tcell-to-gocui event conversion out of pollEvent A following commit needs pollEvent to attach information from the replayed-event wrappers to the GocuiEvent it returns. With the conversion inlined there is no seam to do that in, because every branch of the type switch returns directly. Co-Authored-By: Claude Fable 5 --- pkg/gocui/tcell_driver.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 226ee0580..857e35bfc 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -300,6 +300,10 @@ func (g *Gui) pollEvent() GocuiEvent { tev = <-Screen.EventQ() } + return gocuiEventFromTcellEvent(tev) +} + +func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { switch tev := tev.(type) { case *tcell.EventInterrupt: return GocuiEvent{Type: eventInterrupt} From 664a65d5843765b6a0e89f2267c276c6e1bc1aab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:45:52 +0200 Subject: [PATCH 28/94] Track replayed test input as busy from the moment it is submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests synchronize with lazygit through the task manager: after submitting an input event, the test driver waits until the program goes idle before asserting. But a submitted event only got its task once the main loop picked it up from the events channel; while it was still in flight (handed to the poller goroutine, or sitting in the channel), no task existed for it, so the program could look idle even though input was still pending. The edge-triggered idle protocol mostly papers over this: each wait is satisfied by the *next* busy-to-idle transition, which in practice is the one produced by processing the submitted event. It only goes wrong when some other task (e.g. a background refresh) completes in that window, producing an edge the waiting test mistakes for its own — a rare source of test flakes. The next commit replaces that protocol with a level-triggered one, for which the window would be fatal rather than rare: a wait falling into the gap would return immediately. Close the gap by creating the task on the test goroutine before the event is submitted, and carrying it through the poller into the main loop, which uses it instead of creating its own. The new Replay* methods own this invariant, and the replayed-events channels are no longer exported, so tests can't submit an untracked event. Co-Authored-By: Claude Fable 5 --- pkg/gocui/gui.go | 46 ++++++++++++++++++++++++++++++++++----- pkg/gocui/tcell_driver.go | 28 +++++++++++++++++++----- pkg/gui/gui_driver.go | 16 +++++++------- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ceb570c59..e5588e262 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -125,8 +125,11 @@ type clickInfo struct { // and keybindings. type Gui struct { RecordingConfig - // ReplayedEvents is for passing pre-recorded input events, for the purposes of testing - ReplayedEvents replayedEvents + // replayedEvents is for passing simulated input events, for the purposes + // of testing. Events must be submitted through the Replay* methods, which + // attach a task to each event; pushing into the channels directly would + // bypass the busy-tracking that integration tests rely on. + replayedEvents replayedEvents playRecording bool tabClickBindings []*tabClickBinding @@ -255,7 +258,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.taskManager = newTaskManager() if opts.PlayRecording { - g.ReplayedEvents = replayedEvents{ + g.replayedEvents = replayedEvents{ Keys: make(chan *TcellKeyEventWrapper), Resizes: make(chan *TcellResizeEventWrapper), MouseEvents: make(chan *TcellMouseEventWrapper), @@ -291,6 +294,30 @@ func (g *Gui) NewBackgroundTask() *TaskImpl { return g.taskManager.NewTask(true) } +// ReplayKeyEvent simulates a key press, as if the user had typed it. It's used +// by integration tests. The event carries a task, so that the program counts +// as busy from before the event is submitted until the main loop has fully +// processed it; the test driver relies on this when it waits for the program +// to go idle after submitting an event. (If the task were only created once +// the main loop picks the event up, there would be a window in which the event +// is still in flight but nothing counts as busy.) +func (g *Gui) ReplayKeyEvent(ev *TcellKeyEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.Keys <- ev +} + +// ReplayMouseEvent is like ReplayKeyEvent, but for mouse events. +func (g *Gui) ReplayMouseEvent(ev *TcellMouseEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.MouseEvents <- ev +} + +// ReplayFocusEvent is like ReplayKeyEvent, but for focus events. +func (g *Gui) ReplayFocusEvent(ev *TcellFocusEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.FocusEvents <- ev +} + // Busy reports whether any foreground work is in flight, ignoring the event // currently being processed on the main goroutine (see currentTask). Background // routines (auto-fetch etc.) don't count. It's used to decide whether it's safe @@ -948,7 +975,12 @@ func (g *Gui) processEvent() error { // are always the primary event here. select { case ev := <-g.gEvents: - task := g.NewTask() + // Replayed test events already carry their task (see ReplayKeyEvent); + // organic events get theirs here. + task := ev.task + if task == nil { + task = g.NewTask() + } g.currentTask = task defer func() { g.currentTask = nil; task.Done() }() @@ -992,7 +1024,11 @@ func (g *Gui) processRemainingEvents() (bool, error) { select { case ev := <-g.gEvents: contentOnly = false - if err := g.handleError(g.handleEvent(&ev)); err != nil { + err := g.handleError(g.handleEvent(&ev)) + if ev.task != nil { + ev.task.Done() + } + if err != nil { return false, err } default: diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 857e35bfc..885bcbabb 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -172,6 +172,12 @@ type GocuiEvent struct { Focused bool Start bool N int + + // task tracks the processing of this event for idle detection. Events + // replayed by integration tests carry a task from the moment they are + // submitted (see Gui.ReplayKeyEvent); for organic events it is nil, and + // the main loop creates a task when it picks the event up. + task Task } // Event types. @@ -208,6 +214,8 @@ type TcellKeyEventWrapper struct { Mod tcell.ModMask Key tcell.Key Ch string + + task Task // see GocuiEvent.task } func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper { @@ -229,6 +237,8 @@ type TcellMouseEventWrapper struct { Y int ButtonMask tcell.ButtonMask ModMask tcell.ModMask + + task Task // see GocuiEvent.task } func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper { @@ -269,6 +279,8 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { type TcellFocusEventWrapper struct { Timestamp int64 Focused bool + + task Task // see GocuiEvent.task } func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper { @@ -285,22 +297,28 @@ func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event { // pollEvent get tcell.Event and transform it into gocuiEvent func (g *Gui) pollEvent() GocuiEvent { var tev tcell.Event + var task Task if g.playRecording { select { - case ev := <-g.ReplayedEvents.Keys: + case ev := <-g.replayedEvents.Keys: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.Resizes: + task = ev.task + case ev := <-g.replayedEvents.Resizes: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.MouseEvents: + case ev := <-g.replayedEvents.MouseEvents: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.FocusEvents: + task = ev.task + case ev := <-g.replayedEvents.FocusEvents: tev = (ev).toTcellEvent() + task = ev.task } } else { tev = <-Screen.EventQ() } - return gocuiEventFromTcellEvent(tev) + event := gocuiEventFromTcellEvent(tev) + event.task = task + return event } func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index fef33bf66..a54530791 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -33,10 +33,10 @@ func (self *GuiDriver) PressKey(keyStr string) { self.Fail("Unrecognized key: " + keyStr) } - self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( + self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), 0, - ) + )) self.waitTillIdle() } @@ -44,15 +44,15 @@ func (self *GuiDriver) PressKey(keyStr string) { func (self *GuiDriver) Click(x, y int) { self.CheckAllToastsAcknowledged() - self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0, - ) + )) self.waitTillIdle() - self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), 0, - ) + )) self.waitTillIdle() } @@ -60,10 +60,10 @@ func (self *GuiDriver) Click(x, y int) { // learns to reload changed config files. Tests use it to exercise the live // config-reload path. func (self *GuiDriver) FocusIn() { - self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper( + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(true), 0, - ) + )) self.waitTillIdle() } From 0ce857c717b391172261cb2f6498a07dccf6c076 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:56:27 +0200 Subject: [PATCH 29/94] Fix a deadlock between task.Done() and the integration test's idle wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the integration tests in a loop under the race detector eventually hung in demo/bisect. The goroutine dump shows the cycle: a background worker's task.Done() held the task manager's mutex while blocking on the unbuffered idle-listener channel send, and the test runner goroutine — the only reader of that channel — was itself blocked in NewTask on that same mutex, on its way to enqueueing a caption render (SetCaption -> Render -> OnUIThread). Neither side could proceed: the notification couldn't be delivered until the test goroutine got the mutex, and the mutex couldn't be released until the notification was delivered. The root problem is that the busy-to-idle notification is a blocking rendezvous performed while holding the mutex, so it needs the waiter's cooperation at a moment where the waiter may legitimately need the mutex first. Make the notification fire-and-forget instead: WaitUntilIdle waits on a condition variable and re-checks "is any task busy?" under the mutex, and the busy-to-idle transition broadcasts, which never blocks. Waiting is now level-triggered rather than edge-triggered, which is also more robust: a wait can no longer be satisfied by a stale idle transition produced by an unrelated background task, because the predicate is evaluated against the current state. This relies on the previous commit having made replayed input events carry their task from submission; without that, the wait could return in the window where an event is in flight but not yet picked up by the main loop. Co-Authored-By: Claude Fable 5 --- pkg/gocui/gui.go | 10 +++--- pkg/gocui/task_manager.go | 54 ++++++++++++++++++---------- pkg/gocui/task_manager_test.go | 66 ++++++++++++++++++++++++++++++++++ pkg/gui/gui_driver.go | 9 +++-- pkg/gui/test_mode.go | 8 ++--- 5 files changed, 112 insertions(+), 35 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index e5588e262..9da5225c0 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -326,11 +326,11 @@ func (g *Gui) Busy() bool { return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask) } -// An idle listener listens for when the program is idle. This is useful for -// integration tests which can wait for the program to be idle before taking -// the next step in the test. -func (g *Gui) AddIdleListener(c chan struct{}) { - g.taskManager.addIdleListener(c) +// WaitUntilIdle blocks until the program is idle (no busy tasks). This is +// useful for integration tests which want to wait for the program to finish +// processing before taking the next step in the test. +func (g *Gui) WaitUntilIdle() { + g.taskManager.WaitUntilIdle() } // Close finalizes the library. It should be called after a successful diff --git a/pkg/gocui/task_manager.go b/pkg/gocui/task_manager.go index 23ef0f77e..8d6daaa20 100644 --- a/pkg/gocui/task_manager.go +++ b/pkg/gocui/task_manager.go @@ -6,20 +6,23 @@ import "sync" // the main goroutine or a worker goroutine). Used by integration tests // to wait until the program is idle before progressing. type TaskManager struct { - // each of these listeners will be notified when the program goes from busy to idle - idleListeners []chan struct{} - tasks map[int]Task + tasks map[int]Task // auto-incrementing id for new tasks nextId int mutex sync.Mutex + // signalled whenever the program transitions from busy to idle; used by + // WaitUntilIdle + idleCond *sync.Cond } func newTaskManager() *TaskManager { - return &TaskManager{ - tasks: make(map[int]Task), - idleListeners: []chan struct{}{}, + self := &TaskManager{ + tasks: make(map[int]Task), } + self.idleCond = sync.NewCond(&self.mutex) + + return self } func (self *TaskManager) NewTask(background bool) *TaskImpl { @@ -58,8 +61,26 @@ func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool { return false } -func (self *TaskManager) addIdleListener(c chan struct{}) { - self.idleListeners = append(self.idleListeners, c) +// WaitUntilIdle blocks until no task is busy. Integration tests use it to wait +// for the program to finish processing before taking the next step. +func (self *TaskManager) WaitUntilIdle() { + self.mutex.Lock() + defer self.mutex.Unlock() + + for self.hasBusyTask() { + self.idleCond.Wait() + } +} + +// caller must hold self.mutex +func (self *TaskManager) hasBusyTask() bool { + for _, task := range self.tasks { + if task.isBusy() { + return true + } + } + + return false } func (self *TaskManager) withMutex(f func()) { @@ -68,17 +89,12 @@ func (self *TaskManager) withMutex(f func()) { f() - // Check if all tasks are done - for _, task := range self.tasks { - if task.isBusy() { - return - } - } - - // If we get here, all tasks are done, so - // notify listeners that the program is idle - for _, listener := range self.idleListeners { - listener <- struct{}{} + // Wake up any goroutine blocked in WaitUntilIdle. This must not block on + // the waiter (we hold the mutex, and the waiter may itself be trying to + // acquire it, e.g. by creating a task, before it next waits) — which is + // exactly what Broadcast guarantees. + if !self.hasBusyTask() { + self.idleCond.Broadcast() } } diff --git a/pkg/gocui/task_manager_test.go b/pkg/gocui/task_manager_test.go index 7fe706d7a..b83b678ea 100644 --- a/pkg/gocui/task_manager_test.go +++ b/pkg/gocui/task_manager_test.go @@ -2,6 +2,7 @@ package gocui import ( "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -61,3 +62,68 @@ func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) { assert.False(t, tm.hasBusyForegroundTaskExcept(current)) }) } + +func TestTaskManagerWaitUntilIdle(t *testing.T) { + // returnsWithin reports whether f returns within the given duration. + returnsWithin := func(d time.Duration, f func()) bool { + done := make(chan struct{}) + go func() { + f() + close(done) + }() + select { + case <-done: + return true + case <-time.After(d): + return false + } + } + + t.Run("returns immediately when no task was ever created", func(t *testing.T) { + tm := newTaskManager() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("blocks while a task is busy", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(false) + assert.False(t, returnsWithin(50*time.Millisecond, tm.WaitUntilIdle)) + }) + + t.Run("wakes up when the last busy task completes", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + go func() { + time.Sleep(10 * time.Millisecond) + task.Done() + }() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("a paused task counts as idle", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Pause() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("a task completing while nobody waits must not block", func(t *testing.T) { + // This is the deadlock case: the waiter (the integration-test runner) + // is between waits, and itself needs the task manager's mutex (it + // creates a task whenever it enqueues work) before it waits again. The + // idle notification must neither block the completing task while it + // holds the mutex, nor get lost. + tm := newTaskManager() + assert.True(t, returnsWithin(time.Second, func() { + // the program goes idle with nobody waiting... + tm.NewTask(true).Done() + + // ...and creating and completing more tasks afterwards must still + // be possible + task := tm.NewTask(false) + tm.NewTask(false).Done() + task.Done() + })) + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) +} diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index a54530791..31094b253 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -17,10 +17,9 @@ import ( // this gives our integration test a way of interacting with the gui for sending keypresses // and reading state. type GuiDriver struct { - gui *Gui - isIdleChan chan struct{} - toastChan chan string - headless bool + gui *Gui + toastChan chan string + headless bool } var _ integrationTypes.GuiDriver = &GuiDriver{} @@ -79,7 +78,7 @@ func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { - <-self.isIdleChan + self.gui.g.WaitUntilIdle() } func (self *GuiDriver) CheckAllToastsAcknowledged() { diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 2ba381078..2d5958fbb 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -23,12 +23,8 @@ func (gui *Gui) handleTestMode() { } if test != nil { - isIdleChan := make(chan struct{}) - - gui.c.GocuiGui().AddIdleListener(isIdleChan) - waitUntilIdle := func() { - <-isIdleChan + gui.c.GocuiGui().WaitUntilIdle() } go func() { @@ -38,7 +34,7 @@ func (gui *Gui) handleTestMode() { gui.PopupHandler.(*popup.PopupHandler).SetToastFunc( func(message string, kind types.ToastKind) { toastChan <- message }) - test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan, headless: Headless()}) + test.Run(&GuiDriver{gui: gui, toastChan: toastChan, headless: Headless()}) gui.g.Update(func(*gocui.Gui) error { return gocui.ErrQuit From d786c9d79bbf7370705d2140acb7398287b3bcd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 16 Jul 2026 09:11:16 +0200 Subject: [PATCH 30/94] Escape the merge conflicts view before prompting to continue the rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the last conflict of a file is resolved, a files refresh both offers to continue the rebase/merge (if we started it ourselves) and, via its merge-conflicts scope, escapes from the merge conflicts view back to the files context. The two race: the prompt is bounced onto the UI thread by the files worker, while the escape's context push is queued separately by EscapeMerge, and it deliberately refuses to push the files context over a popup. So if the prompt opens first, the escape does nothing, and closing the prompt lands the user in the stale merge conflicts view — usually already emptied by the escape's state reset — instead of the files panel. No later refresh rescues this. Fix this by escaping from the merge conflicts view right before opening the prompt. This runs on the UI thread and doesn't hold the merge conflicts mutex, so it can reset the state and push the files context synchronously; whichever side runs first, the prompt now always opens on top of the files context, and EscapeMerge's guarded push still does nothing only when that's the right thing to do. This is a timing race with no deterministic regression test; it showed up as a rare flake in tests that cancel the continue prompt (e.g. commit/amend_when_there_are_conflicts_and_continue) when looping the integration tests under the race detector. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3c8524ffe..21fdc8a7a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1207,6 +1207,19 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. self.onUIThreadUnlessRepoChanged(env, func() error { + // 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{}) + } return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } From 14d717d77f7ac2e722656d5405cac61b29d54379 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 08:41:25 +0200 Subject: [PATCH 31/94] Bump tcell to an unreleased snapshot to fix a shutdown race tcell's filterEvents goroutine sends events into eventQ with a plain blocking send, while Fini (via finish/finalize) closes eventQ after closing the quit channel. The goroutine can have already committed to the ev = <-inQ select arm when quit is closed, so its send into eventQ races with the close; the race detector flags this (send and close on the same channel are unsynchronized), and if the close wins, the send panics with "send on closed channel". This was caught by the integration tests under the race detector, where every test drives a real tScreen over a MockTerm and tears it down via Fini, but it equally affects real-terminal shutdown. Upstream fixed it in 243630d2 ("Fix screen Init/Fini races") by tracking the filter goroutine in a WaitGroup that finalize waits for before closing eventQ, and guarding the send with a select on quit. That commit is not in a tagged release yet (latest is v3.4.0), so pin the pseudo-version; the delta over v3.4.0 is just this fix, a Windows key-release fix, a cell-rendering perf tweak, and dependency bumps. Co-Authored-By: Claude Fable 5 --- go.mod | 10 ++-- go.sum | 20 ++++---- vendor/github.com/gdamore/tcell/v3/cell.go | 18 ++++--- vendor/github.com/gdamore/tcell/v3/tscreen.go | 28 ++++++++++- .../gdamore/tcell/v3/tty/tty_win.go | 49 ++++++++++++++----- vendor/modules.txt | 10 ++-- 6 files changed, 97 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index c10004176..12af7493b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.4.0 + github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -65,11 +65,11 @@ require ( github.com/onsi/gomega v1.34.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/mod v0.35.0 // indirect + golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index 1ca3151b4..2f734de11 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E= -github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc= +github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU= +github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -139,8 +139,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -168,21 +168,21 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/vendor/github.com/gdamore/tcell/v3/cell.go b/vendor/github.com/gdamore/tcell/v3/cell.go index cbe2732de..b3be03b13 100644 --- a/vendor/github.com/gdamore/tcell/v3/cell.go +++ b/vendor/github.com/gdamore/tcell/v3/cell.go @@ -72,12 +72,18 @@ func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) { if x >= 0 && y >= 0 && x < cb.w && y < cb.h { var cl string c := &cb.cells[(y*cb.w)+x] - g := textWidthOptions.StringGraphemes(str) - for width == 0 && g.Next() { - cluster := g.Value() - cl += cluster - width = g.Width() - str = str[len(cluster):] + if str == c.currStr && c.width > 0 { + // Identical re-Put (a full-screen redraw): the grapheme split is + // unchanged, so reuse the measured width instead of segmenting. + cl, width, str = str, c.width, "" + } else { + g := textWidthOptions.StringGraphemes(str) + for width == 0 && g.Next() { + cluster := g.Value() + cl += cluster + width = g.Width() + str = str[len(cluster):] + } } // Wide characters: we want to mark the "wide" cells diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 2fa180f63..9fe3cf8fe 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -243,6 +243,7 @@ type tScreen struct { legacy bool hasClipboard bool // true if OSC 52 reported via DA1 finiOnce sync.Once + initFiniLock sync.Mutex enterUrl string exitUrl string setWinSize string @@ -258,6 +259,7 @@ type tScreen struct { running bool startTime time.Time wg sync.WaitGroup + eventWg sync.WaitGroup mouseFlags MouseFlags pasteEnabled bool focusEnabled bool @@ -355,6 +357,20 @@ func (t *tScreen) applyEnvironmentOverrides() { } func (t *tScreen) Init() error { + t.initFiniLock.Lock() + defer t.initFiniLock.Unlock() + + t.Lock() + if t.fini { + t.Unlock() + return errors.New("screen finalized") + } + if t.running { + t.Unlock() + return errors.New("already initialized") + } + t.Unlock() + if e := t.initialize(); e != nil { return e } @@ -525,7 +541,9 @@ func (t *tScreen) processInitQ() { func (t *tScreen) filterEvents() chan Event { inQ := make(chan Event, 128) + t.eventWg.Add(1) go func() { + defer t.eventWg.Done() for { var ev Event select { @@ -541,7 +559,11 @@ func (t *tScreen) filterEvents() chan Event { } default: - t.eventQ <- ev + select { + case t.eventQ <- ev: + case <-t.quit: + return + } } } }() @@ -596,6 +618,9 @@ func (t *tScreen) prepareCursorStyles() { } func (t *tScreen) Fini() { + t.initFiniLock.Lock() + defer t.initFiniLock.Unlock() + // Ensure that enough time passes for terminals to finish sending // their initial response (gnome-terminal sends terminal dimensions // asynchronously later than the response to primary DA for some reason.) @@ -1659,6 +1684,7 @@ func (t *tScreen) Beep() error { func (t *tScreen) finalize() { t.disengage() _ = t.tty.Close() + t.eventWg.Wait() close(t.eventQ) } diff --git a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go index 853c1e136..e17cd7dab 100644 --- a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go +++ b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go @@ -20,6 +20,7 @@ package tty import ( "encoding/binary" "errors" + "fmt" "sync" "syscall" "time" @@ -98,6 +99,38 @@ type inputRecord struct { data [16]byte } +func encodeWinKeyRecord(data [16]byte, surrogate *rune) []byte { + keyDown := binary.LittleEndian.Uint32(data[0:]) != 0 + repeat := binary.LittleEndian.Uint16(data[4:]) + virtualKey := binary.LittleEndian.Uint16(data[6:]) + scanCode := binary.LittleEndian.Uint16(data[8:]) + // we normally only expect to see ascii, but paste data may come in as UTF-16. + wc := rune(binary.LittleEndian.Uint16(data[10:])) + controlState := binary.LittleEndian.Uint32(data[12:]) + + if virtualKey != 0 || scanCode != 0 { + kd := 0 + if keyDown { + kd = 1 + } + return fmt.Appendf(nil, "\x1b[%d;%d;%d;%d;%d;%d_", + virtualKey, scanCode, wc, kd, controlState, max(1, repeat)) + } + + if !keyDown { + return nil + } + + var encoded []byte + decodedRunes := decodeUTF16Rune(surrogate, wc) + for range max(1, repeat) { + for _, decoded := range decodedRunes { + encoded = append(encoded, []byte(string(decoded))...) + } + } + return encoded +} + type winTty struct { buf chan byte out syscall.Handle @@ -207,17 +240,11 @@ func (w *winTty) getConsoleInput() error { ir := rec[i] switch ir.typ { case keyEvent: - // we normally only expect to see ascii, but paste data may come in as UTF-16. - wc := rune(binary.LittleEndian.Uint16(ir.data[10:])) - for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) { - for _, chr := range []byte(string(decoded)) { - // We normally expect only to see ASCII (win32-input-mode), - // but apparently pasted data can arrive in UTF-16 here. - select { - case w.buf <- chr: - case <-w.stopQ: - break loop - } + for _, chr := range encodeWinKeyRecord(ir.data, &w.surrogate) { + select { + case w.buf <- chr: + case <-w.stopQ: + break loop } } diff --git a/vendor/modules.txt b/vendor/modules.txt index 3b50432bf..2c60d9282 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -48,7 +48,7 @@ github.com/fatih/color # github.com/gdamore/encoding v1.0.1 ## explicit; go 1.9 github.com/gdamore/encoding -# github.com/gdamore/tcell/v3 v3.4.0 +# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 ## explicit; go 1.25.0 github.com/gdamore/tcell/v3 github.com/gdamore/tcell/v3/color @@ -175,7 +175,7 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.35.0 +# golang.org/x/mod v0.36.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile @@ -192,10 +192,10 @@ golang.org/x/sync/semaphore golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.43.0 +# golang.org/x/term v0.44.0 ## explicit; go 1.25.0 golang.org/x/term -# golang.org/x/text v0.37.0 +# golang.org/x/text v0.38.0 ## explicit; go 1.25.0 golang.org/x/text/cases golang.org/x/text/encoding @@ -208,7 +208,7 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.44.0 +# golang.org/x/tools v0.45.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c From 38e1fe0493325283128519f36746ffdc7aa30894 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 11:56:47 +0200 Subject: [PATCH 32/94] Add tests showing ghost views when branches/commits are not their panel's first tab With gui.sidePanels, a panel's gocui window is named after its first tab. The transient contexts (remoteBranches, subCommits, commitFiles) initially point at the windows "branches" and "commits"; when the config gives no panel that name, their views end up visible at full screen size, covering every side panel below them in z-order (issue #5823). Co-Authored-By: Claude Fable 5 --- pkg/integration/tests/test_list.go | 2 ++ .../tests/ui/branches_not_first_tab.go | 34 +++++++++++++++++++ .../tests/ui/commits_not_first_tab.go | 32 +++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 pkg/integration/tests/ui/branches_not_first_tab.go create mode 100644 pkg/integration/tests/ui/commits_not_first_tab.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index abf13073e..8213d5159 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -484,6 +484,8 @@ var tests = []*components.IntegrationTest{ tag.Reset, tag.ResetToDuplicateNamedBranch, ui.Accordion, + ui.BranchesNotFirstTab, + ui.CommitsNotFirstTab, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, ui.HideSidePanel, diff --git a/pkg/integration/tests/ui/branches_not_first_tab.go b/pkg/integration/tests/ui/branches_not_first_tab.go new file mode 100644 index 000000000..0e60b5ed9 --- /dev/null +++ b/pkg/integration/tests/ui/branches_not_first_tab.go @@ -0,0 +1,34 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BranchesNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With gui.sidePanels grouping branches behind another tab, no ghost view must appear over the side panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"worktrees", "branches", "remotes"}, + {"files"}, + {"commits", "tags"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The remote branches and sub-commits views are only shown after + // drilling into a remote or a branch; at startup both must be hidden, + // or they'd cover the side panels. + t.Views().RemoteBranches(). + /* EXPECTED: + IsInvisible() + ACTUAL: */ + IsVisible() + t.Views().SubCommits().IsInvisible() + }, +}) diff --git a/pkg/integration/tests/ui/commits_not_first_tab.go b/pkg/integration/tests/ui/commits_not_first_tab.go new file mode 100644 index 000000000..db50e4c08 --- /dev/null +++ b/pkg/integration/tests/ui/commits_not_first_tab.go @@ -0,0 +1,32 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitsNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With gui.sidePanels grouping commits behind another tab, no ghost view must appear over the side panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"branches", "worktrees", "remotes"}, + {"files"}, + {"tags", "commits"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The commit files view is only shown after drilling into a commit; at + // startup it must be hidden, or it'd cover the side panels. + t.Views().CommitFiles(). + /* EXPECTED: + IsInvisible() + ACTUAL: */ + IsVisible() + }, +}) From bf4f5827e748574b9267520f48856e2c5ff7f049 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 12:00:00 +0200 Subject: [PATCH 33/94] Don't show a transient view whose window is not part of the layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With gui.sidePanels, a panel's gocui window is named after its first tab, so when branches is grouped behind, say, worktrees, there is no window called "branches" at all. The transient contexts (remoteBranches, subCommits, commitFiles) initially point at the windows "branches" and "commits", and layout() showed their views whenever the window-to-view map named them as their window's current view — without checking that the window exists in the layout. Since the map is seeded from the contexts themselves, a window that no panel owns keeps naming a transient view as its current view, and that view had just been parked at full screen size (the fallback for views in unlaid-out windows), so it covered every side panel below it in z-order. Only show a transient view if its window actually received dimensions in this layout. Fixes #5823. Co-Authored-By: Claude Fable 5 --- pkg/gui/layout.go | 9 ++++++++- pkg/integration/tests/ui/branches_not_first_tab.go | 3 --- pkg/integration/tests/ui/commits_not_first_tab.go | 3 --- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index de3bdbe9b..bcdc0edfc 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -154,7 +154,14 @@ func (gui *Gui) layout(g *gocui.Gui) error { if err != nil && !errors.Is(err, gocui.ErrUnknownView) { return err } - view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName() + // A transient view is visible if it is the view its window is currently + // showing — but only if that window is part of the layout at all. For a + // window without dimensions, setViewFromDimensions parks the view at full + // screen size in the background, so making it visible would cover all + // windows below it. + _, windowHasDimensions := viewDimensions[context.GetWindowName()] + view.Visible = windowHasDimensions && + gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName() } if gui.PrevLayout.Information != informationStr { diff --git a/pkg/integration/tests/ui/branches_not_first_tab.go b/pkg/integration/tests/ui/branches_not_first_tab.go index 0e60b5ed9..47e8a4fd5 100644 --- a/pkg/integration/tests/ui/branches_not_first_tab.go +++ b/pkg/integration/tests/ui/branches_not_first_tab.go @@ -25,10 +25,7 @@ var BranchesNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ // drilling into a remote or a branch; at startup both must be hidden, // or they'd cover the side panels. t.Views().RemoteBranches(). - /* EXPECTED: IsInvisible() - ACTUAL: */ - IsVisible() t.Views().SubCommits().IsInvisible() }, }) diff --git a/pkg/integration/tests/ui/commits_not_first_tab.go b/pkg/integration/tests/ui/commits_not_first_tab.go index db50e4c08..505aa4307 100644 --- a/pkg/integration/tests/ui/commits_not_first_tab.go +++ b/pkg/integration/tests/ui/commits_not_first_tab.go @@ -24,9 +24,6 @@ var CommitsNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ // The commit files view is only shown after drilling into a commit; at // startup it must be hidden, or it'd cover the side panels. t.Views().CommitFiles(). - /* EXPECTED: IsInvisible() - ACTUAL: */ - IsVisible() }, }) From 74a77e58be5f8c478481fe4ee3b1ce6de3351600 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 12:02:23 +0200 Subject: [PATCH 34/94] Assign the transient contexts' initial windows from the side panel config The transient contexts (remoteBranches, subCommits, commitFiles) take over the window of the context they are drilled into from, but until then they carry a hardcoded initial window ("branches" or "commits"). Under a gui.sidePanels config where those tabs aren't their panel's first, no window of that name exists, leaving the window-to-view map with entries for windows the layout never produces. The previous commit made such entries harmless, but there's no reason to have contexts point at nonexistent windows in the first place; assign them the window hosting branches or commits instead, which the config validation guarantees to exist. Co-Authored-By: Claude Fable 5 --- pkg/gui/side_panels.go | 9 +++++++++ pkg/gui/side_panels_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index 361d54fb1..8b327b197 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -125,4 +125,13 @@ func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) { ctx.SetWindowName(name) } } + + // The transient contexts take over the window of the context they are + // drilled into from, but they need a valid initial window before their + // first use. Assign the window hosting branches or commits, respectively; + // unlike e.g. remotes, those tabs can't be hidden, so their windows are + // always part of the layout. + contextTree.RemoteBranches.SetWindowName(contextTree.Branches.GetWindowName()) + contextTree.SubCommits.SetWindowName(contextTree.Branches.GetWindowName()) + contextTree.CommitFiles.SetWindowName(contextTree.LocalCommits.GetWindowName()) } diff --git a/pkg/gui/side_panels_test.go b/pkg/gui/side_panels_test.go index b1240c357..24813b4ae 100644 --- a/pkg/gui/side_panels_test.go +++ b/pkg/gui/side_panels_test.go @@ -28,3 +28,23 @@ func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) { assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles())) assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree()))) } + +// The transient contexts must end up in windows that exist under the configured +// panel layout, or their views would be laid out for a window that is never +// shown. +func TestAssignSidePanelWindowsCoversTransientContexts(t *testing.T) { + gui := NewDummyGui() + gui.c.UserConfig().Gui.SidePanels = []config.SidePanel{ + {"worktrees", "branches", "remotes"}, + {"files"}, + {"tags", "commits"}, + {"stash"}, + } + + contextTree := gui.contextTree() + gui.assignSidePanelWindows(contextTree) + + assert.Equal(t, "worktrees", contextTree.RemoteBranches.GetWindowName()) + assert.Equal(t, "worktrees", contextTree.SubCommits.GetWindowName()) + assert.Equal(t, "tags", contextTree.CommitFiles.GetWindowName()) +} From d097519c05174ed2dd98251e9aafc4d38c6074f3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:13:33 +0200 Subject: [PATCH 35/94] Serialize concurrent writes to the streamed command's output writer runAndStreamAux funnels a command's stdout and stderr into a single cmdWriter (the command-log panel, or a buffer when output is suppressed) from two separate goroutines: stderr through the MultiWriter set on cmd.Stderr, and stdout through the onRun callback. Those goroutines wrote the shared writer without any synchronization, racing on the prefixWriter's prefixWritten flag and interleaving the two streams. Wrap the writer so its writes are serialized. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/cmd_obj_runner.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index b70668431..74b7e721d 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -244,6 +244,10 @@ func (self *cmdObjRunner) runAndStreamAux( } else { cmdWriter = self.guiIO.newCmdWriterFn() } + // The command's stdout and stderr are streamed to cmdWriter concurrently + // from separate goroutines (stderr via the MultiWriter below, stdout via + // onRun), so it must be safe for concurrent writes. + cmdWriter = &synchronizedWriter{writer: cmdWriter} if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) @@ -451,6 +455,20 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede } } +// synchronizedWriter serializes writes to its underlying writer so that it can +// be written from multiple goroutines at once (see runAndStreamAux, which +// streams a command's stdout and stderr to one writer from two goroutines). +type synchronizedWriter struct { + mutex deadlock.Mutex + writer io.Writer +} + +func (self *synchronizedWriter) Write(p []byte) (int, error) { + self.mutex.Lock() + defer self.mutex.Unlock() + return self.writer.Write(p) +} + type Buffer struct { b bytes.Buffer m deadlock.Mutex From a61be44e920948cfef3dbe5adf0e7893e1447dd3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 20:12:36 +0200 Subject: [PATCH 36/94] Wait for the streamed command's output goroutine before reading its buffers runAndStreamAux reads the stdout buffer (and, when output is suppressed, the combinedOutput buffer) for its error message after handler.wait() returns, but the goroutine that fills those buffers by draining the command's output isn't awaited, so the reads raced its final writes. Own the goroutine here rather than letting the onRun callbacks spawn it, and join it before reading the buffers. The pty reader reaches EOF on its own once the process exits, but the non-pty pipe never does, so its handler now closes the read end to unblock the reader; the pipe is synchronous, so by the time the command has exited all of its output has already been read and nothing is lost. This also plugs the goroutine that the non-pty streaming path previously leaked on every command. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/cmd_obj_runner.go | 37 +++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index 74b7e721d..6178ac816 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -227,9 +227,7 @@ type cmdHandler struct { func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error { return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { - go func() { - _, _ = io.Copy(cmdWriter, handler.stdoutPipe) - }() + _, _ = io.Copy(cmdWriter, handler.stdoutPipe) }) } @@ -280,10 +278,29 @@ func (self *cmdObjRunner) runAndStreamAux( t := time.Now() - onRun(handler, cmdWriter) + // Stream the command's output on a goroutine while it runs, but keep a + // handle on it: the buffers it fills (stdout, and combinedOutput when + // output is suppressed) must not be read below until it has finished. + streamingDone := make(chan struct{}) + go utils.Safe(func() { + defer close(streamingDone) + onRun(handler, cmdWriter) + }) err = handler.wait() + // The command has exited; wait for the streaming goroutine to drain the + // last of its output before reading those buffers. A pty reader reaches + // EOF on its own now the process is gone, but the non-pty pipe never does, + // so close it to unblock the reader — the pipe is synchronous, so all + // output has already been read by now and nothing is lost. + if !cmdObj.ShouldUsePty() { + if closeErr := handler.close(); closeErr != nil { + self.log.Error(closeErr) + } + } + <-streamingDone + self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) if err != nil { @@ -358,10 +375,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest( return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { tr := io.TeeReader(handler.stdoutPipe, cmdWriter) - - go utils.Safe(func() { - self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj) - }) + self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj) }) } @@ -500,8 +514,11 @@ func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error return &cmdHandler{ stdoutPipe: stdoutReader, stdinPipe: buf, - close: func() error { return nil }, - wait: cmd.Wait, + // Closing the read end makes a blocked read on it return, which is how + // runAndStreamAux unblocks and joins the streaming goroutine once the + // command has finished (the pipe delivers no EOF of its own). + close: func() error { return stdoutReader.Close() }, + wait: cmd.Wait, }, nil } From 2765147b711826b54408b48b5e3850fa09d39e61 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:11:30 +0200 Subject: [PATCH 37/94] Note in AGENTS.md that gocui lives in-tree Agents (and humans new to the repo) repeatedly go looking for the gocui sources in go.mod, go.sum, or the module cache and hit a dead end, because gocui is a fork maintained in-tree under pkg/gocui rather than pulled in as a dependency. Record that in AGENTS.md so the dead end is avoided. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 947add510..88e818249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,3 +411,12 @@ Never run `find` (or similar) from `/` or other paths outside the project. All third-party code we use is vendored under `vendor/`, so dependency sources are reachable from inside the working tree — search there instead of the host filesystem. + +## gocui is in-tree, not a dependency + +The `gocui` TUI library is a fork maintained directly in this repo under +`pkg/gocui` — it's an ordinary package, not a Go module dependency. Don't look +for it in `go.mod`/`go.sum` or the module cache (`$GOMODCACHE`); it isn't +there. When you need to read or change gocui internals (the task manager, the +event loop, worker/UI-thread dispatch, view rendering), edit `pkg/gocui` +directly. From 36f193a2e86bfd19c8e651d0bfa7f23524e125c4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 12:00:33 +0200 Subject: [PATCH 38/94] Remove return value from PromptToContinueRebase It always returned nil. --- pkg/gui/controllers/helpers/merge_and_rebase_helper.go | 4 +--- pkg/gui/controllers/helpers/refresh_helper.go | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index b0c53b831..267453a86 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -321,7 +321,7 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { } // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress -func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { +func (self *MergeAndRebaseHelper) PromptToContinueRebase() { self.continueRebasePromptShowing = true self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Continue, @@ -373,8 +373,6 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { return nil }, }) - - return nil } // DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?" diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 21fdc8a7a..dd6b87ae0 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1220,7 +1220,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.mergeConflictsHelper.ResetMergeState() self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } - return self.mergeAndRebaseHelper.PromptToContinueRebase() + self.mergeAndRebaseHelper.PromptToContinueRebase() + return nil }) } } else { From 504e5b3f741d0149deda0ffdbbb7fa60be3e7669 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 12:01:26 +0200 Subject: [PATCH 39/94] Remove the error return value from the onUIThreadUnlessRepoChanged lambda All clients pass a function that returns nil. --- pkg/gui/controllers/helpers/refresh_helper.go | 72 +++++++------------ 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dd6b87ae0..47cd51ff7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -358,9 +358,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. Guard on the generation so a // repo switch mid-refresh drops it, like the model bounces. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) - return nil }) }) } @@ -622,7 +621,7 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() @@ -635,7 +634,6 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS return nil }) } - return nil }) } } @@ -687,7 +685,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -721,12 +719,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Contexts().LocalCommits.FocusLine(true) - return nil }) } - return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -856,10 +852,9 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit if err != nil { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) - return nil }) self.refreshView(self.c.Contexts().SubCommits, env) @@ -899,10 +894,9 @@ func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFile if err != nil { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() - return nil }) self.refreshView(self.c.Contexts().CommitFiles, env) return nil @@ -921,10 +915,9 @@ func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, comm } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState - return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -937,9 +930,8 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Tags = tags - return nil }) self.refreshView(self.c.Contexts().Tags, env) @@ -966,10 +958,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh }) }, func() { - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Contexts().Branches.HandleRender() self.refreshStatus(env) - return nil }) }) if err != nil { @@ -981,14 +972,14 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(env, func() error { + 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 nil + return } self.appliedBranchLoadSeq = loadSeq @@ -1031,7 +1022,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh // Need to re-render the commits view because the visualization of local // branch heads might have changed self.c.Contexts().LocalCommits.HandleRender() - return nil }) self.refreshView(self.c.Contexts().Branches, env) @@ -1066,12 +1056,13 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // bumps the generation, so a write captured under the old generation must not // clobber the new repo's state. The generation is captured once at the start of // the refresh and carried in env (see refreshEnv). -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) { +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func()) { self.onUIThread(env.background, func() error { if self.c.State().GetRepoGeneration() != env.generation { return nil } - return f() + f() + return nil }) } @@ -1206,7 +1197,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(env, func() error { + 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 @@ -1221,7 +1212,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } self.mergeAndRebaseHelper.PromptToContinueRebase() - return nil }) } } else { @@ -1232,13 +1222,12 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // Guard on the generation like the sibling PromptToContinueRebase // bounce above: if the repo was switched while this refresh was in // flight, a prompt showing now belongs to the new repo, so leave it be. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() - return nil }) } - self.onUIThreadUnlessRepoChanged(env, func() error { + 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 { @@ -1253,7 +1242,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files fileTreeViewModel.SetTree() - return nil }) return nil @@ -1308,7 +1296,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, @@ -1318,7 +1306,6 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) } - return nil }) self.refreshView(self.c.Contexts().ReflogCommits, env) @@ -1331,7 +1318,7 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env return nil, err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 @@ -1351,7 +1338,6 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env } } } - return nil }) self.refreshView(self.c.Contexts().Remotes, env) @@ -1371,9 +1357,8 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees - return nil }) // need to refresh branches because the branches view shows worktrees against @@ -1386,9 +1371,8 @@ func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(filterPath) - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().StashEntries = stashEntries - return nil }) self.refreshView(self.c.Contexts().Stash, env) @@ -1399,7 +1383,7 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(env, func() error { + 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 @@ -1407,13 +1391,12 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh - return nil + 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) - return nil }) } @@ -1443,7 +1426,7 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // switched while the refresh was in flight, its model write was already // dropped, so there's nothing fresh to render — and the captured context // belongs to the old repo's now-replaced context tree anyway. - self.onUIThreadUnlessRepoChanged(env, func() error { + 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) @@ -1462,16 +1445,14 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { self.searchHelper.ReApplySearch(context) return nil }) - return nil }) } func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) { clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil - return nil }) } @@ -1624,14 +1605,13 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(env, func() error { + 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) - return nil }) } From 4acfc8806506b8abfac9140d0e25e170720c8d0e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 14:02:04 +0200 Subject: [PATCH 40/94] Replace the BLOCK_UI refresh mode with a BatchUIUpdates flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCK_UI ran the whole refresh on the UI thread and parked it in a wg.Wait for the duration, so the UI (and its spinner) froze while the git work ran. Blocking the UI was never the point — the point was to apply all the scopes' updates in one frame instead of a per-scope cascade — and if we genuinely wanted to block input it should span the whole operation, not just its refresh, which needs a gocui-level mechanism we don't have. So drop the mode and add a BatchUIUpdates option that achieves the "one frame" effect without blocking: each scope's UI-thread bounce is collected into a shared refreshBounceBatch during the refresh, and once every scope has finished they're all applied inside a single OnUIThread task. gocui drains every queued event before it redraws, so one task means one repaint. The refresh itself now runs SYNC — on a worker when issued from one (checkout, move-to-new-branch, the rebase-edit result handling), so the UI thread stays live and the spinner keeps animating. The batch needs a mutex because the scopes add concurrently from their worker goroutines, and a closed flag so that any bounces enqueued after the flush starts — the nested ones a flushed bounce produces in turn, e.g. scrolling the selection into view — are dispatched immediately as ordinary follow-ups rather than collected into a batch that nothing will drain. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 110 +++++++++++++----- pkg/gui/controllers/helpers/refs_helper.go | 12 +- .../controllers/local_commits_controller.go | 4 +- pkg/gui/types/refresh.go | 12 +- 4 files changed, 100 insertions(+), 38 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 47cd51ff7..2f0f10657 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -96,6 +96,49 @@ type refreshEnv struct { // 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) { @@ -121,18 +164,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr ) } - // f runs on the UI thread when the refresh was initiated there, and also for - // BLOCK_UI, which dispatches f onto the UI thread regardless of the caller. - // Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker. - // This, not calledFromWorker alone, is what decides whether a scope capture - // runs inline or has to hop (see captureOnUIThread). - fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + // 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. We check the caller's own goroutine here, before a BLOCK_UI - // refresh dispatches f onto the UI thread, so it holds regardless of the - // mode. goid stays out of production control flow (debug only). + // 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") } @@ -144,6 +184,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr 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 { @@ -376,6 +419,20 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr 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 @@ -387,14 +444,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } } - if options.Mode == types.BLOCK_UI { - self.c.OnUIThread(func() error { - f() - return nil - }) - return - } - f() } @@ -482,8 +531,6 @@ func getModeName(mode types.RefreshMode) string { return "sync" case types.ASYNC: return "async" - case types.BLOCK_UI: - return "block-ui" default: return "unknown mode" } @@ -1057,13 +1104,21 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // 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()) { - self.onUIThread(env.background, func() error { + wrapper := func() { if self.c.State().GetRepoGeneration() != env.generation { - return nil + return } f() - return nil - }) + } + + // 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 @@ -1094,12 +1149,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // -// The inline case matters for correctness as much as the hop: a SYNC or -// BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers -// run, so a scope worker that tried to hop to the UI thread there would +// 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. This is why BLOCK_UI (which always runs on the -// UI thread, even from a worker caller) captures inline rather than hopping. +// thread — avoids that entirely. func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { if fRunsOnUIThread { fn() diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 5f07b8ea6..dda3d918a 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -56,7 +56,8 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -368,7 +369,8 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest } self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -534,7 +536,8 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -570,7 +573,8 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 23e94adf7..ade7d1a9a 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -604,7 +604,7 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{Mode: types.BLOCK_UI}) + err, types.RefreshOptions{BatchUIUpdates: true}) }) } @@ -628,7 +628,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, - types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error { + types.RefreshOptions{BatchUIUpdates: true, Then: func() error { todos := make([]*models.Commit, 0, len(commitsToEdit)-1) for _, c := range commitsToEdit[:len(commitsToEdit)-1] { // Merge commits can't be set to "edit", so just skip them diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index f4041bb2e..7b304b15d 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -28,9 +28,8 @@ const ( type RefreshMode int const ( - SYNC RefreshMode = iota // wait until everything is done before returning - ASYNC // return immediately, allowing each independent thing to update itself - BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete + SYNC RefreshMode = iota // wait until everything is done before returning + ASYNC // return immediately, allowing each independent thing to update itself ) // CommitSelectionBehavior controls which local commit is selected after the @@ -74,7 +73,12 @@ const ( type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything - Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI + Mode RefreshMode // one of SYNC (default) and ASYNC + + // If true, hold off on updating the UI until all scopes have finished + // refreshing and then apply them together in a single frame, rather than + // letting each scope update the UI as soon as it's done. + BatchUIUpdates bool // Controls which local branch is selected after the refresh. Defaults to // KeepBranchSelectionByName. From d70d70aad2007df539b682767c47b5eba282b5e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:31:01 +0200 Subject: [PATCH 41/94] Get rid of pointless f() indirection 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. --- pkg/gui/controllers/helpers/refresh_helper.go | 514 +++++++++--------- 1 file changed, 255 insertions(+), 259 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2f0f10657..0da2f08d0 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -177,274 +177,270 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } - f := func() { - // Capture the repo generation once, here at the start, so every scope's - // bounce is guarded against the same baseline. - env := refreshEnv{ - background: options.Background, - generation: self.c.State().GetRepoGeneration(), - } - if options.BatchUIUpdates { - env.batch = &refreshBounceBatch{} - } + // 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) - } + 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) - } + // 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) + // 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() - } + 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 }) - } - - 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) + } 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)) + }) } } - f() + 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. From f319522d5b91a93170b5150cdaed296771a32444 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:37:16 +0200 Subject: [PATCH 42/94] Remove fRunsOnUIThread variable; use calledFromWorker directly There is no f() function any more, so a variable named "f runs on" doesn't make sense. And we also don't need it any more; it used to be necessary when its meaning was not exactly the same as `!calledFromWorker`, but also included the BLOCK_UI case, but that has changed several commits ago. --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 0da2f08d0..002391aa1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -164,12 +164,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr ) } - // 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). @@ -280,7 +274,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedCommits capturedCommitState var capturedReflog capturedReflogState var capturedBranches capturedBranchState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() @@ -314,7 +308,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // 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() { + self.captureOnUIThread(calledFromWorker, env.background, func() { rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() }) refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) @@ -322,7 +316,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedSubCommits = self.captureSubCommitState() }) refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) @@ -331,7 +325,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // 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() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() }) refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) @@ -340,7 +334,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedFiles = self.captureFilesState() }) fileWg.Add(1) @@ -352,7 +346,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.STASH) { var stashFilterPath string - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() }) refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) @@ -367,7 +361,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // 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() { + self.captureOnUIThread(calledFromWorker, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() }) branchesAndRemotesWg.Add(1) @@ -1142,7 +1136,7 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // 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 +// 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 @@ -1150,8 +1144,8 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // 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 { +func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) { + if !calledFromWorker { fn() return } From bfd3b7b47e57b423c11cdfba58f0a2a5938eaabb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:47:06 +0200 Subject: [PATCH 43/94] Allow Then and BatchUIUpdates to work with an async refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Then, and BatchUIUpdates, previously only worked for a SYNC refresh: the calling goroutine blocked in wg.Wait until every scope had finished, and only then flushed the batch and ran Then. An ASYNC refresh had no such join point — it dispatched each scope onto its own worker and returned right away — so Then was forbidden (it would have run before the scopes finished) and a batch would never be drained. Give the async path a join of its own. Both paths now register their scopes in the WaitGroup, and the finishing work — wg.Wait, the batch flush, and Then — moves into a closure. A SYNC refresh runs it inline as before; an ASYNC refresh dispatches it to a worker, so the caller still returns immediately but the batch and Then run once every scope is done. Besides lifting the restriction, this makes SYNC and ASYNC differ only in whether the finishing work blocks the caller, which is what lets a later commit drop the mode entirely and key the choice off the calling thread instead. --- pkg/gui/controllers/helpers/refresh_helper.go | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 002391aa1..dd6d948f7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -142,10 +142,6 @@ func (self *refreshBounceBatch) close() []func() { } 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)) @@ -234,16 +230,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg := sync.WaitGroup{} refresh := func(name string, f func()) { + wg.Add(1) + // 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 { + defer wg.Done() f() return nil }) } else { - wg.Add(1) go utils.Safe(func() { t := time.Now() defer wg.Done() @@ -410,30 +408,41 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.refreshStatus(env) - wg.Wait() + 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 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 options.Then != nil { - // Queue Then via OnUIThread so it runs *after* the refresh-scope - // functions' model-update bounces (which are already queued by - // now), not synchronously here — at this point the workers have - // returned but their bounces haven't been processed yet, so - // invoking Then synchronously would run it on a model that's - // still pre-refresh. - self.onUIThread(env.background, options.Then) + if options.Mode == types.SYNC { + waitAndFinalize() + } else { + self.onWorker(env.background, func(t gocui.Task) error { + waitAndFinalize() + return nil + }) } } From 63bd2d98c0fa56e5d6c852b933b59f283e64c63c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:46:57 +0200 Subject: [PATCH 44/94] Show a waiting status while creating a branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a branch checks it out, and checking out a distant ref (a tag or a commit far from HEAD) can take a noticeable while. NewBranch ran that synchronously in the prompt's confirm handler, on the UI thread, so the UI froze — no spinner, no repaint — until it finished. Move the branch creation (and the autostash path) onto a worker with a waiting status, mirroring CheckoutRef, and refresh from the worker so the UI thread stays live and the spinner keeps animating. Push the branches context from the refresh's Then rather than up front: the refresh already batches its UI updates, so switching panels there lands the switch in the same frame as the refreshed branch list instead of flashing the pre-refresh list while the checkout is still running. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refs_helper.go | 76 +++++++++++++--------- pkg/i18n/english.go | 2 + 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index dda3d918a..e91a6c500 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -364,16 +364,22 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest } refresh := func() { - if self.c.Context().Current() != self.c.Contexts().Branches { - self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) - } - - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, + Then: func() error { + // Switch to the branches panel only now, in the same batched + // frame that applies the refreshed data, so the panel switch + // and the new branch appear together rather than flashing the + // old branch list while the checkout is still in progress. + if self.c.Context().Current() != self.c.Contexts().Branches { + self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) + } + return nil + }, }) } @@ -387,34 +393,44 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest if newBranchName != suggestedBranchName { newBranchFunc = self.c.Git().Branch.NewWithoutTracking } - if err := newBranchFunc(newBranchName, from); err != nil { - if IsSwitchBranchUncommittedChangesError(err) { - // offer to autostash changes - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.AutoStashTitle, - Prompt: self.c.Tr.AutoStashPrompt, - HandleConfirm: func() error { - if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { - return err - } - if err := newBranchFunc(newBranchName, from); err != nil { - return err - } - err := self.c.Git().Stash.Pop(0) - // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). - refresh() - return err - }, - }) - return nil + // Creating the branch checks it out, which can take a while when + // the ref we're branching off is distant, so do it on a worker. + return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error { + if err := newBranchFunc(newBranchName, from); err != nil { + if IsSwitchBranchUncommittedChangesError(err) { + // offer to autostash changes + self.c.OnUIThread(func() error { + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.AutoStashTitle, + Prompt: self.c.Tr.AutoStashPrompt, + HandleConfirm: func() error { + return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error { + if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { + return err + } + if err := newBranchFunc(newBranchName, from); err != nil { + return err + } + err := self.c.Git().Stash.Pop(0) + // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). + refresh() + return err + }) + }, + }) + return nil + }) + + return nil + } + + return err } - return err - } - - refresh() - return nil + refresh() + return nil + }) }, }) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 22b05e6c0..2e83fed9b 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -427,6 +427,7 @@ type TranslationSet struct { UndoingStatus string RedoingStatus string CheckingOutStatus string + CreatingBranchStatus string CommittingStatus string RewordingStatus string RevertingStatus string @@ -1576,6 +1577,7 @@ func EnglishTranslationSet() *TranslationSet { UndoingStatus: "Undoing", RedoingStatus: "Redoing", CheckingOutStatus: "Checking out", + CreatingBranchStatus: "Creating branch", CommittingStatus: "Committing", RewordingStatus: "Rewording", RevertingStatus: "Reverting", From 8580c78cc020e79d4bfd6805f76eeadc973c4df5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:57:07 +0200 Subject: [PATCH 45/94] Derive sync vs async refresh from the calling thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a refresh should block or run in the background was controlled by the Mode field, but that always lined up with the calling thread: a UI-thread Refresh must not block the UI, while a RefreshFromWorker runs on a worker where blocking is exactly what we want. Now that Then and BatchUIUpdates work regardless of that choice, drop Mode from the decision and key it off calledFromWorker instead: - Refresh (UI thread) runs its scopes and the finishing step (wait, batch flush, Then) on workers, so the caller returns immediately — what ASYNC used to mean. - RefreshFromWorker runs them on the calling worker, blocking it until everything is done — what SYNC used to mean. Demos keep taking the blocking, inline path so everything still lands in one deterministic frame. In practice this flips the handful of RefreshFromWorker calls that passed ASYNC — they now block their worker until the refresh finishes, keeping the waiting-status spinner up until the UI actually updates — and the many UI-thread refreshes that defaulted to SYNC, which no longer freeze the UI thread while the git work runs. Mode now only feeds the log line; the next commit removes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dd6d948f7..2482e6015 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -232,10 +232,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh := func(name string, f func()) { wg.Add(1) - // 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 { + // A refresh issued from the UI thread must not block it, so its scopes + // run as their own worker tasks and the caller returns immediately (the + // finishing step below is dispatched to a worker too). A refresh issued + // from a worker blocks that worker instead, running its scopes as plain + // goroutines that it joins. In a demo we always take the blocking path + // so everything updates in a single, deterministic frame. + if !self.c.InDemo() && !calledFromWorker { self.onWorker(env.background, func(t gocui.Task) error { defer wg.Done() f() @@ -436,7 +439,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } } - if options.Mode == types.SYNC { + // 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 { From 88811e6795c0bc15e50fea1a0488cbff561165fd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 16:10:01 +0200 Subject: [PATCH 46/94] Remove the RefreshMode field With sync vs async now derived from the calling thread, the Mode field and its SYNC/ASYNC constants no longer carry any information: Refresh is always async, RefreshFromWorker always sync. Drop the field, the type, and the Mode argument at every call site, and reduce the debug log's mode name to a plain sync/async derived from calledFromWorker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/bisect_controller.go | 2 +- pkg/gui/controllers/branches_controller.go | 12 +++---- .../controllers/commits_files_controller.go | 2 +- .../custom_patch_options_menu_action.go | 2 +- pkg/gui/controllers/diffing_menu_action.go | 8 ++--- pkg/gui/controllers/files_controller.go | 12 +++---- pkg/gui/controllers/global_controller.go | 2 +- pkg/gui/controllers/helpers/bisect_helper.go | 2 +- .../controllers/helpers/branches_helper.go | 11 +++--- .../controllers/helpers/cherry_pick_helper.go | 2 +- .../controllers/helpers/credentials_helper.go | 2 +- pkg/gui/controllers/helpers/diff_helper.go | 2 +- pkg/gui/controllers/helpers/fixup_helper.go | 2 +- pkg/gui/controllers/helpers/gpg_helper.go | 6 ++-- .../helpers/merge_and_rebase_helper.go | 10 +++--- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++--------- pkg/gui/controllers/helpers/refs_helper.go | 7 +--- .../helpers/working_tree_helper.go | 5 ++- .../controllers/helpers/worktree_helper.go | 4 +-- .../controllers/local_commits_controller.go | 35 +++++++++---------- .../controllers/merge_conflicts_controller.go | 2 +- .../controllers/patch_building_controller.go | 2 +- .../controllers/remote_branches_controller.go | 2 +- pkg/gui/controllers/remotes_controller.go | 2 -- pkg/gui/controllers/sub_commits_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 6 ++-- .../controllers/workspace_reset_controller.go | 14 ++++---- pkg/gui/gui.go | 8 ++--- .../custom_commands/handler_creator.go | 2 +- pkg/gui/types/refresh.go | 8 ----- 31 files changed, 84 insertions(+), 120 deletions(-) diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index eb568240b..685f932b1 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -282,7 +282,7 @@ func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToR } if waitToReselect { - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}, Then: selectFn}) return nil } diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a886a410b..a73ee3bc2 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -331,7 +331,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, @@ -355,7 +354,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, @@ -546,7 +544,7 @@ func (self *BranchesController) forceCheckout() error { if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) @@ -600,7 +598,6 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er } self.c.Refresh(types.RefreshOptions{ - Mode: types.ASYNC, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -734,7 +731,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { WorktreePath: worktreePath, }, ) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return err } @@ -743,7 +740,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return err }) } @@ -760,7 +757,7 @@ func (self *BranchesController) createSortMenu() error { if self.c.UserConfig().Git.LocalBranchSortOrder != sortOrder { self.c.UserConfig().Git.LocalBranchSortOrder = sortOrder self.c.Contexts().Branches.SetSelection(0) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return nil } return nil @@ -788,7 +785,6 @@ func (self *BranchesController) rename(branch *models.Branch) error { // onto the UI thread, so the re-selection (which reads Model.Branches) has to run in // Then; reading it inline here would see the previous model. self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES}, Then: func() error { // now that we've got our stuff again we need to find that branch and reselect it. diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index b90e14b74..14e1c1a50 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -324,7 +324,7 @@ func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 3d15ce899..2882ab808 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -269,7 +269,7 @@ func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) diff --git a/pkg/gui/controllers/diffing_menu_action.go b/pkg/gui/controllers/diffing_menu_action.go index 3ae5903d9..8372d7919 100644 --- a/pkg/gui/controllers/diffing_menu_action.go +++ b/pkg/gui/controllers/diffing_menu_action.go @@ -22,7 +22,7 @@ func (self *DiffingMenuAction) Call() error { OnPress: func() error { self.c.Modes().Diffing.Ref = name // can scope this down based on current view but too lazy right now - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, @@ -38,7 +38,7 @@ func (self *DiffingMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(), HandleConfirm: func(response string) error { self.c.Modes().Diffing.Ref = response - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) @@ -54,7 +54,7 @@ func (self *DiffingMenuAction) Call() error { Label: self.c.Tr.SwapDiff, OnPress: func() error { self.c.Modes().Diffing.Reverse = !self.c.Modes().Diffing.Reverse - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, @@ -62,7 +62,7 @@ func (self *DiffingMenuAction) Call() error { Label: self.c.Tr.ExitDiffMode, OnPress: func() error { self.c.Modes().Diffing = diffing.New() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index b70b67ab7..bf73d4c8b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -636,7 +636,7 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) self.context().HandleFocus(types.OnFocusOpts{}) return nil @@ -921,7 +921,7 @@ func (self *FilesController) toggleStagedAll() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) self.context().HandleFocus(types.OnFocusOpts{}) return nil @@ -1204,7 +1204,7 @@ func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayF // Whenever we switch between untracked and other filters, we need to refresh the files view // because the untracked files filter applies when running `git status`. if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } else { self.c.PostRefreshUpdate(self.context()) } @@ -1740,7 +1740,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), @@ -1766,7 +1766,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, Keys: menuKey('u'), @@ -1808,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) return nil }) } diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 8b9871294..77ef29070 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -158,7 +158,7 @@ func (self *GlobalController) createCustomPatchOptionsMenu() error { } func (self *GlobalController) refresh() error { - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/helpers/bisect_helper.go b/pkg/gui/controllers/helpers/bisect_helper.go index 6ce517dac..bc9548c4c 100644 --- a/pkg/gui/controllers/helpers/bisect_helper.go +++ b/pkg/gui/controllers/helpers/bisect_helper.go @@ -31,5 +31,5 @@ func (self *BisectHelper) Reset() error { } func (self *BisectHelper) PostBisectCommandRefresh() { - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}}) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 5c72bacfd..83735d87d 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -49,7 +49,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error self.c.Contexts().Branches.CollapseRangeSelectionToTop() return nil }) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) }) @@ -87,7 +87,7 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { self.c.OnUIThread(func() error { self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() @@ -161,7 +161,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc self.c.Contexts().Branches.CollapseRangeSelectionToTop() return nil }) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) }, @@ -325,7 +325,6 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B return nil }) self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, }) return nil @@ -346,7 +345,6 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] return nil }) self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, }) return nil @@ -407,7 +405,6 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er // returns (where it would still see the previous branches). self.c.RefreshFromWorker(types.RefreshOptions{ Scope: scope, - Mode: types.SYNC, Background: background, Then: func() error { if fetchErr != nil { @@ -458,7 +455,7 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) error { self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC, Background: background}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background}) return err } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index 673f657f5..b69b68514 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -95,7 +95,7 @@ func (self *CherryPickHelper) Paste() error { cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}) if err != nil { return result } diff --git a/pkg/gui/controllers/helpers/credentials_helper.go b/pkg/gui/controllers/helpers/credentials_helper.go index 9b2198ccb..7c765020e 100644 --- a/pkg/gui/controllers/helpers/credentials_helper.go +++ b/pkg/gui/controllers/helpers/credentials_helper.go @@ -33,7 +33,7 @@ func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.Cr HandleConfirm: func(input string) error { ch <- input + "\n" - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, HandleClose: func() error { diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 668ee916a..6af3b2b5c 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -94,7 +94,7 @@ func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string { func (self *DiffHelper) ExitDiffMode() error { self.c.Modes().Diffing = diffing.New() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index dfde8365b..a958e58a8 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -137,7 +137,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error { if err := self.c.Git().WorkingTree.StageAll(true); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } self.c.Contexts().LocalCommits.SetSelection(index) diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index fd74a400b..9c7667a6d 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -26,7 +26,7 @@ func (self *GpgHelper) WithGpgHandling( onSuccess func() error, refreshScope []types.RefreshableView, ) error { - refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope} + refreshOptions := types.RefreshOptions{Scope: refreshScope} return self.withGpgHandling( cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions) } @@ -40,8 +40,8 @@ func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit( waitingStatus string, onSuccess func() error, ) error { - failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC} - successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit} + failureRefreshOptions := types.RefreshOptions{} + successRefreshOptions := types.RefreshOptions{CommitSelection: types.SelectHeadCommit} return self.withGpgHandling( cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions) } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 267453a86..7f1a31188 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -133,7 +133,6 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) self.refreshAfterMergeOrRebase(types.RefreshOptions{ - Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), }, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() @@ -144,7 +143,6 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) return self.checkMergeOrRebaseImpl(result, types.RefreshOptions{ - Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), }, calledFromWorker) } @@ -258,7 +256,7 @@ func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types } func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { - return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) + return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{}) } // Like CheckMergeOrRebase, but for operations that create a new commit at HEAD @@ -267,7 +265,7 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { // before the refresh. func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, - types.RefreshOptions{Mode: types.SYNC, CommitSelection: commitSelectionAfterMerge(result == nil)}) + types.RefreshOptions{CommitSelection: commitSelectionAfterMerge(result == nil)}) } func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { @@ -346,7 +344,7 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() { // to read it in Then; reading it inline here would see the previous // model. self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, + Scope: []types.RefreshableView{types.FILES}, Then: func() error { unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) if len(unstagedFiles) > 0 { @@ -667,7 +665,7 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch if err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2482e6015..fdb526549 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -147,15 +147,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.c.Log.Infof("Refresh took %s", time.Since(t)) }() + // 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 in %s mode", - getModeName(options.Mode), - ) + self.c.Log.Infof("refreshing all scopes (%s)", syncOrAsync) } else { self.c.Log.Infof( - "refreshing the following scopes in %s mode: %s", - getModeName(options.Mode), + "refreshing the following scopes (%s): %s", + syncOrAsync, strings.Join(getScopeNames(options.Scope), ","), ) } @@ -530,17 +533,6 @@ func getScopeNames(scopes []types.RefreshableView) []string { }) } -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 diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index e91a6c500..675c332a0 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -56,7 +56,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, @@ -161,7 +160,6 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN // Do a sync refresh to make sure the new branch is visible, // so that we see an inline status when checking it out self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}, }) return checkout(localBranchName, true) @@ -365,7 +363,6 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest refresh := func() { self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -552,7 +549,6 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -577,7 +573,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick) - err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC}) + err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{}) if err != nil { return err } @@ -589,7 +585,6 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 36dfd2032..5f68cb822 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -238,7 +238,6 @@ func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, Then: handler, }) @@ -260,7 +259,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro if err := self.c.Git().WorkingTree.StageAll(false); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) return retry() }, @@ -360,7 +359,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin } err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) return err } diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 980d810ae..35d515d6e 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f return then(task) } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } @@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta return then(task) } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index ade7d1a9a..19e0a7c17 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -476,7 +476,7 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } @@ -495,7 +495,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str if err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } @@ -700,7 +700,7 @@ func (self *LocalCommitsController) updateTodosWithFlag(action todo.TodoCommand, } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, + Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) return nil @@ -742,7 +742,6 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -757,7 +756,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) + err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -770,7 +769,6 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -785,7 +783,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) + err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -798,7 +796,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) } @@ -875,7 +873,7 @@ func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } @@ -891,7 +889,7 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) }, @@ -910,7 +908,7 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) }, @@ -948,7 +946,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}); err != nil { return err } @@ -996,7 +994,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) }) @@ -1096,7 +1094,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) }, @@ -1149,7 +1147,7 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) self.context().MoveSelectedLine(-selectionOffset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{}) }) } @@ -1196,7 +1194,7 @@ func (self *LocalCommitsController) openSearch() error { // we usually lazyload these commits but now that we're searching we need to load them now if self.context().GetLimitCommits() { self.context().SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) } return self.c.Helpers().Search.OpenSearchPrompt(self.context()) @@ -1217,7 +1215,7 @@ func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { self.c.Refresh( - types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}, ) return nil }) @@ -1271,7 +1269,6 @@ func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { self.c.Refresh( types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}, }, ) @@ -1316,7 +1313,7 @@ func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) } } } diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 898eb356b..1af53fded 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -302,7 +302,7 @@ func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.S func (self *MergeConflictsController) onLastConflictResolved() { // as part of refreshing files, we handle the situation where a file has had // its merge conflicts resolved. - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *MergeConflictsController) openMergeConflictMenu() error { diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index d596c2ead..fe28ca603 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -229,7 +229,7 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) self.c.Helpers().PatchBuilding.Escape() return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{}) }) } diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index f70145d7b..0d50068f3 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -158,7 +158,7 @@ func (self *RemoteBranchesController) createSortMenu() error { if self.c.UserConfig().Git.RemoteBranchSortOrder != sortOrder { self.c.UserConfig().Git.RemoteBranchSortOrder = sortOrder self.c.Contexts().RemoteBranches.SetSelection(0) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}}) } return nil }, diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index d4c838f7c..76bd16bb1 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -163,7 +163,6 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl // affordable. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, - Mode: types.SYNC, Then: func() error { // Select the remote for idx, remote := range self.c.Model().Remotes { @@ -371,7 +370,6 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } refreshOptions := types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, - Mode: types.SYNC, } if branchName != "" { err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go index 8799cd3c6..d3d0c0b98 100644 --- a/pkg/gui/controllers/sub_commits_controller.go +++ b/pkg/gui/controllers/sub_commits_controller.go @@ -66,7 +66,7 @@ func (self *SubCommitsController) GetOnFocus() func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.SUB_COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUB_COMMITS}}) } } } diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index fafd4e7dd..61b92747b 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 2a59af5ec..a6c0e7e14 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) err := self.c.Git().Tag.LocalDelete(tag.Name) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return err }) } @@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, @@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, diff --git a/pkg/gui/controllers/workspace_reset_controller.go b/pkg/gui/controllers/workspace_reset_controller.go index 9a9005254..27e736648 100644 --- a/pkg/gui/controllers/workspace_reset_controller.go +++ b/pkg/gui/controllers/workspace_reset_controller.go @@ -46,7 +46,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -68,7 +68,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -86,7 +86,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -111,7 +111,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -129,7 +129,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -147,7 +147,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -170,7 +170,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 931b0909e..25e543abb 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -391,7 +391,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context } gui.c.Log.Info("Receiving focus - refreshing") - gui.helpers.Refresh.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.helpers.Refresh.Refresh(types.RefreshOptions{}) return reloadErr } @@ -815,7 +815,7 @@ func NewGui( func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) }, - func() error { gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); return nil }, + func() error { gui.c.Refresh(types.RefreshOptions{}); return nil }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, gui.createMenu, @@ -1023,7 +1023,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb return err } - gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.c.Refresh(types.RefreshOptions{}) return nil } @@ -1100,7 +1100,7 @@ func (gui *Gui) loadNewRepo() error { return err } - gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.c.Refresh(types.RefreshOptions{}) if err := gui.os.UpdateWindowTitle(); err != nil { return err diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 4eb762019..6046d974a 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses } output, err := cmdObj.RunWithOutput() - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 7b304b15d..937c3a30e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -25,13 +25,6 @@ const ( PULL_REQUESTS ) -type RefreshMode int - -const ( - SYNC RefreshMode = iota // wait until everything is done before returning - ASYNC // return immediately, allowing each independent thing to update itself -) - // CommitSelectionBehavior controls which local commit is selected after the // commits list is reloaded by a refresh. type CommitSelectionBehavior int @@ -73,7 +66,6 @@ const ( type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything - Mode RefreshMode // one of SYNC (default) and ASYNC // If true, hold off on updating the UI until all scopes have finished // refreshing and then apply them together in a single frame, rather than From 6893d9a7590239c7a39aae3d2f3fd09a3e62f12d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:40:41 +0200 Subject: [PATCH 47/94] Add gocui primitives to block input during an operation Long-running operations that lazygit drives itself (rebases, and the commit surgery built on them) can be corrupted by keys the user presses while they run: pressing e to start an interactive rebase, then up+d before it finishes, must act on the resulting todo list, not race the rebase. WithWaitingStatusSync gets this today only as a side effect of freezing the UI thread, which the rest of this branch is moving away from. Add a nestable counter, BeginBlockingEvents/EndBlockingEvents, that withholds input at the event-dispatch layer without freezing anything: while blocked, key events are buffered and replayed in order once the count returns to zero (so they act on the now-current context), mouse clicks and hover are dropped (replaying them against a changed layout would target the wrong thing), and scrolling, resize, focus and all rendering keep flowing. These are the reusable core; a gui-level helper that brackets them around a worker operation follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/block_events_test.go | 98 ++++++++++++++++++++++++++++++++++ pkg/gocui/gui.go | 73 +++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 pkg/gocui/block_events_test.go diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go new file mode 100644 index 000000000..277bac89a --- /dev/null +++ b/pkg/gocui/block_events_test.go @@ -0,0 +1,98 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEventWithheldWhileBlocking(t *testing.T) { + scenarios := []struct { + name string + event GocuiEvent + withheld bool + }{ + {"key", GocuiEvent{Type: eventKey, Key: NewKeyRune('x')}, true}, + {"mouse click", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}, true}, + {"mouse scroll", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseWheelDown)}, false}, + {"mouse move", GocuiEvent{Type: eventMouseMove}, true}, + {"resize", GocuiEvent{Type: eventResize}, false}, + {"focus", GocuiEvent{Type: eventFocus}, false}, + {"paste", GocuiEvent{Type: eventPaste}, false}, + {"error", GocuiEvent{Type: eventError}, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.withheld, eventWithheldWhileBlocking(&s.event)) + }) + } +} + +// setupKeyRecorder wires a keybinding on a focused view that records each time +// it fires, and returns the key event that triggers it plus the record slice. +func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) { + t.Helper() + + _, _ = g.SetView("main", 0, 0, 80, 22, 0) + _, err := g.SetCurrentView("main") + assert.NoError(t, err) + + fired := []int{} + callCount := 0 + key := NewKeyRune('x') + g.SetKeybinding("main", key, func(*Gui, *View) error { + callCount++ + fired = append(fired, callCount) + return nil + }) + + return GocuiEvent{Type: eventKey, Key: key}, &fired +} + +func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + + // Not blocking: the key dispatches immediately. + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.Len(t, *fired, 1) + + // While blocking: the key is buffered, not dispatched. + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking") + + // Unblocking replays the buffered keys. + assert.NoError(t, g.EndBlockingEvents()) + assert.Len(t, *fired, 3, "both buffered keys should replay on unblock") + assert.Empty(t, g.bufferedKeyEvents) +} + +func TestBlockingEvents_NestsWithCounter(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + + g.BeginBlockingEvents() + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&keyEvent)) + + // The inner block ending still leaves us blocked: no replay yet. + assert.NoError(t, g.EndBlockingEvents()) + assert.Empty(t, *fired) + + // Only the outermost block ending replays. + assert.NoError(t, g.EndBlockingEvents()) + assert.Len(t, *fired, 1) +} + +func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) { + g := newTestGui(t) + + g.BeginBlockingEvents() + click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)} + assert.NoError(t, g.handleEvent(&click)) + assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered") + assert.NoError(t, g.EndBlockingEvents()) +} diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 9da5225c0..700c2b54c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -210,6 +210,14 @@ type Gui struct { // MainLoop starts. IsUIThread compares against it. Written once, read from // worker goroutines, so it's atomic. uiThreadID atomic.Int64 + + // blockInputCount, when greater than zero, withholds keyboard input from + // the handlers: key events are buffered into bufferedKeyEvents and replayed + // once the count drops back to zero, while mouse clicks and hover are + // dropped outright. It's a counter so blocking can nest. Both fields are + // only touched on the UI thread. See BeginBlockingEvents. + blockInputCount int + bufferedKeyEvents []GocuiEvent } type NewGuiOpts struct { @@ -806,6 +814,42 @@ func (g *Gui) IsUIThread() bool { return goid.Get() == g.uiThreadID.Load() } +// BeginBlockingEvents starts withholding keyboard input from the handlers, so a +// long-running operation can't be disrupted by keys the user presses while it +// runs. Keys are buffered and replayed once EndBlockingEvents balances this +// call; mouse clicks and hover are dropped for the duration. Scrolling, +// resizing, focus changes and all rendering keep working throughout. It's a +// counter, so blocking nests; every call must be paired with EndBlockingEvents. +// +// Must be called on the UI thread. Callers arrange this by beginning the block +// synchronously from the keybinding handler, before dispatching the operation +// to a worker — beginning it from the worker would race the next queued +// keypress, which is exactly the input we mean to withhold. +func (g *Gui) BeginBlockingEvents() { + g.blockInputCount++ +} + +// EndBlockingEvents balances a BeginBlockingEvents call. When the last nested +// block ends, the keys buffered while blocked are replayed in order through the +// normal dispatch path, so they act on the now-current context (a key whose +// binding no longer exists is simply ignored, just as if it had been pressed +// now). Must be called on the UI thread. +func (g *Gui) EndBlockingEvents() error { + g.blockInputCount-- + if g.blockInputCount > 0 { + return nil + } + + buffered := g.bufferedKeyEvents + g.bufferedKeyEvents = nil + for i := range buffered { + if err := g.handleEvent(&buffered[i]); err != nil { + return err + } + } + return nil +} + // OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the // caller until f has run, returning f's error. Use it to read UI-thread-owned // state (the model, contexts) from a worker without racing the UI thread. @@ -1052,6 +1096,17 @@ func (g *Gui) processRemainingEvents() (bool, error) { // handleEvent handles an event, based on its type (key-press, error, // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { + if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) { + if ev.Type == eventKey { + // Buffer keys so they replay against fresh state on unblock. + g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev) + } + // Mouse clicks and hover fall through to here without being buffered: + // replaying them once the operation has changed the layout underneath + // them would target the wrong thing, so we drop them outright. + return nil + } + switch ev.Type { case eventKey, eventMouse, eventMouseMove: return g.onKey(ev) @@ -1070,6 +1125,24 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { } } +// eventWithheldWhileBlocking reports whether an event must not reach the +// handlers while input is blocked (see BeginBlockingEvents). Key events are +// withheld (buffered for replay); mouse clicks and hover are withheld (dropped). +// Everything else — mouse scrolling, resize, focus, paste, errors — flows +// through as usual. +func eventWithheldWhileBlocking(ev *GocuiEvent) bool { + switch ev.Type { + case eventKey: + return true + case eventMouse: + return !IsMouseScrollKey(ev.Key.KeyName()) + case eventMouseMove: + return true + default: + return false + } +} + func (g *Gui) onResize() { // not sure if we actually need this // g.screen.Sync() From 707b04a8c2b1b93908e41343abf19b877e9ef9a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:43:06 +0200 Subject: [PATCH 48/94] Add a WithWaitingStatusBlockingInput helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bracket gocui's BeginBlockingEvents/EndBlockingEvents around a worker operation that shows a waiting status. The block is begun synchronously on the UI thread, before the operation is dispatched to a worker, so no keypress can slip through in between; it ends via OnUIThread once the operation and its refresh have applied their UI updates, so the replayed keys act on the refreshed state. This composes what the retiring WithWaitingStatusSync did — show a status and block input — but on a worker, so the UI keeps rendering (spinner animates, model updates land) instead of freezing. Callers follow in subsequent commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/app_status_helper.go | 24 +++++++++ pkg/gui/gui.go | 3 ++ pkg/gui/popup/popup_handler.go | 50 +++++++++++-------- pkg/gui/types/common.go | 1 + 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 90b87b3b8..a366909f6 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -86,6 +86,30 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. }) } +// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks +// keyboard input for the whole duration of the operation: keys the user presses +// while it runs are buffered and replayed against the post-operation state (see +// gocui.BeginBlockingEvents). Use it for operations that manipulate an +// in-progress rebase or otherwise rewrite commits, where a racing keypress +// would target the wrong commit or todo. +// +// Must be called on the UI thread: the block is begun synchronously here, before +// the operation is dispatched to a worker, so no keypress can slip through in +// between. +func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { + self.c.GocuiGui().BeginBlockingEvents() + self.c.OnWorker(func(task gocui.Task) error { + // End the block once the operation and its refresh have applied their UI + // updates: OnUIThread queues this after the refresh's model bounces and + // Then (which RefreshFromWorker has already enqueued by the time f + // returns), so the replayed keys act on the refreshed state. + defer self.c.OnUIThread(func() error { + return self.c.GocuiGui().EndBlockingEvents() + }) + return self.WithWaitingStatusImpl(message, f, task, false) + }) +} + func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 25e543abb..a6baaf373 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -820,6 +820,9 @@ func NewGui( func() types.Context { return gui.State.ContextMgr.Current() }, gui.createMenu, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, + func(message string, f func(gocui.Task) error) { + gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) + }, func(message string, f func() error) error { return gui.helpers.AppStatus.WithWaitingStatusSync(message, f) }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index ab067410d..23084f9ce 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -13,16 +13,17 @@ import ( type PopupHandler struct { *common.Common - createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) - onErrorFn func() error - popContextFn func() - currentContextFn func() types.Context - createMenuFn func(types.CreateMenuOptions) error - withWaitingStatusFn func(message string, f func(gocui.Task) error) - withWaitingStatusSyncFn func(message string, f func() error) error - toastFn func(message string, kind types.ToastKind) - getPromptInputFn func() string - inDemo func() bool + createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) + onErrorFn func() error + popContextFn func() + currentContextFn func() types.Context + createMenuFn func(types.CreateMenuOptions) error + withWaitingStatusFn func(message string, f func(gocui.Task) error) + withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) + withWaitingStatusSyncFn func(message string, f func() error) error + toastFn func(message string, kind types.ToastKind) + getPromptInputFn func() string + inDemo func() bool } var _ types.IPopupHandler = &PopupHandler{} @@ -35,23 +36,25 @@ func NewPopupHandler( currentContextFn func() types.Context, createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), + withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), withWaitingStatusSyncFn func(message string, f func() error) error, toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, ) *PopupHandler { return &PopupHandler{ - Common: common, - createPopupPanelFn: createPopupPanelFn, - onErrorFn: onErrorFn, - popContextFn: popContextFn, - currentContextFn: currentContextFn, - createMenuFn: createMenuFn, - withWaitingStatusFn: withWaitingStatusFn, - withWaitingStatusSyncFn: withWaitingStatusSyncFn, - toastFn: toastFn, - getPromptInputFn: getPromptInputFn, - inDemo: inDemo, + Common: common, + createPopupPanelFn: createPopupPanelFn, + onErrorFn: onErrorFn, + popContextFn: popContextFn, + currentContextFn: currentContextFn, + createMenuFn: createMenuFn, + withWaitingStatusFn: withWaitingStatusFn, + withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn, + withWaitingStatusSyncFn: withWaitingStatusSyncFn, + toastFn: toastFn, + getPromptInputFn: getPromptInputFn, + inDemo: inDemo, } } @@ -76,6 +79,11 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e return nil } +func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error { + self.withWaitingStatusBlockingInputFn(message, f) + return nil +} + func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error { return self.withWaitingStatusSyncFn(message, f) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 08ff53bb0..964143c5a 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -161,6 +161,7 @@ type IPopupHandler interface { // Shows a popup prompting the user for input. Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error + WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error WithWaitingStatusSync(message string, f func() error) error Menu(opts CreateMenuOptions) error Toast(message string) From 62098ca6039bb7fd40c4fda2d266292e1de9996f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:53:44 +0200 Subject: [PATCH 49/94] Pass captured state to moveFixupCommitToOwnerStackedBranch It reads the selected index and the commits and branches models to decide where to move the fixup commit. Take those as parameters, captured on the UI thread by the callers, so the function can run its rebase on a worker without reading the model there. No behavior change; the callers still run on the UI thread for now. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/local_commits_controller.go | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 19e0a7c17..17547f6c8 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -985,12 +985,15 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) + selectedIdx := self.context().GetSelectedLineIdx() + commits := self.c.Model().Commits + branches := self.c.Model().Branches return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } - if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { + if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil { return err } @@ -1023,7 +1026,12 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }) } -func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCommit *models.Commit) error { +// moveFixupCommitToOwnerStackedBranch takes state captured on the UI thread +// (the selected index and the commits and branches models) so that it can run +// its rebase on a worker without reading the model there. +func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch( + targetCommit *models.Commit, selectedIdx int, commits []*models.Commit, branches []*models.Branch, +) error { if self.c.Git().Version.IsOlderThan(2, 38, 0) { // Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't // move the commit down with older versions, as it would break the stack. @@ -1051,9 +1059,9 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo } headOfOwnerBranchIdx := -1 - for i := self.context().GetSelectedLineIdx(); i > 0; i-- { - if lo.SomeBy(self.c.Model().Branches, func(b *models.Branch) bool { - return b.CommitHash == self.c.Model().Commits[i].Hash() + for i := selectedIdx; i > 0; i-- { + if lo.SomeBy(branches, func(b *models.Branch) bool { + return b.CommitHash == commits[i].Hash() }) { headOfOwnerBranchIdx = i break @@ -1064,7 +1072,7 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo return nil } - return self.c.Git().Rebase.MoveFixupCommitDown(self.c.Model().Commits, headOfOwnerBranchIdx) + return self.c.Git().Rebase.MoveFixupCommitDown(commits, headOfOwnerBranchIdx) } func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error { @@ -1085,12 +1093,15 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc PreserveMessage: false, OnConfirm: func(summary string, description string) error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) + selectedIdx := self.context().GetSelectedLineIdx() + commits := self.c.Model().Commits + branches := self.c.Model().Branches return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } - if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { + if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil { return err } From 352883c52b442e751ab5b12e2c87bcfac306f1f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:57:26 +0200 Subject: [PATCH 50/94] Run the sync commit-surgery ops on a worker with input blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move, revert, squash-fixups, create-fixup and cherry-pick paste ran their rebase synchronously on the UI thread via WithWaitingStatusSync, which froze the UI for the duration but kept the user from disrupting the operation with a stray keypress. Switch them to WithWaitingStatusBlockingInput so the git work runs on a worker — the UI keeps rendering and the spinner animates — while input stays blocked for the whole operation, as before. discard-patch-from-commit also moves off WithWaitingStatusSync, but as a plain WithWaitingStatus: it's a custom-patch command, and those don't block input. The bodies now follow the worker conventions: model state they need is captured on the UI thread before dispatching, self.c.Refresh becomes RefreshFromWorker, and CheckMergeOrRebase uses the worker variant. An operation that moves the selection does so in the refresh's Then, so it lands in the same frame as the refreshed commit list; squash sets it as an absolute index there, because the shorter list would clamp a relative move. --- .../controllers/helpers/cherry_pick_helper.go | 22 +++-- .../controllers/local_commits_controller.go | 92 +++++++++++++------ .../controllers/patch_building_controller.go | 16 +++- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index b69b68514..fc96b9d1b 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -82,9 +83,9 @@ func (self *CherryPickHelper) Paste() error { "numCommits": strconv.Itoa(len(self.getData().CherryPickedCommits)), }), HandleConfirm: func() error { - return self.c.WithWaitingStatusSync(self.c.Tr.CherryPickingStatus, func() error { - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - + mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + cherryPickedCommits := self.getData().CherryPickedCommits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) if mustStash { @@ -93,9 +94,9 @@ func (self *CherryPickHelper) Paste() error { } } - cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{BatchUIUpdates: true}) if err != nil { return result } @@ -109,14 +110,19 @@ func (self *CherryPickHelper) Paste() error { return result } if !isInCherryPick { - self.getData().DidPaste = true - self.rerender() + // DidPaste and the re-render touch mode state and contexts, + // so run them on the UI thread. + self.c.OnUIThread(func() error { + self.getData().DidPaste = true + self.rerender() + return nil + }) if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { return err } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: []types.RefreshableView{types.STASH, types.FILES}, }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 17547f6c8..1234b80ec 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -748,15 +748,24 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { + commits := self.c.Model().Commits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx) - if err == nil { - self.context().MoveSelection(1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) + err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + CommitSelection: types.KeepCommitSelectionIndex, + // Move the selection to follow the moved commit, in Then so it + // lands in the same frame as the refreshed commit list. + Then: func() error { + if err == nil { + self.context().MoveSelection(1) + self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + } + return nil + }, + }) }) } @@ -775,15 +784,24 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { + commits := self.c.Model().Commits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx) - if err == nil { - self.context().MoveSelection(-1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) + err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + CommitSelection: types.KeepCommitSelectionIndex, + // Move the selection to follow the moved commit, in Then so it + // lands in the same frame as the refreshed commit list. + Then: func() error { + if err == nil { + self.context().MoveSelection(-1) + self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + } + return nil + }, + }) }) } @@ -936,9 +954,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end Prompt: promptText, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) - return self.c.WithWaitingStatusSync(self.c.Tr.RevertingStatus, func() error { - mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - + mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error { if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { return err @@ -946,7 +963,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{BatchUIUpdates: true}); err != nil { return err } @@ -954,7 +972,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end if err := self.c.Git().Stash.Pop(0); err != nil { return err } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: []types.RefreshableView{types.STASH, types.FILES}, }) } @@ -988,7 +1006,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } @@ -997,7 +1015,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true}) return nil }) }) @@ -1096,7 +1114,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } @@ -1105,7 +1123,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true}) return nil }) }, @@ -1153,12 +1171,28 @@ func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error { func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error { selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx) - return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error { + // The squashed fixups above the selection are removed, so the selection moves + // up by that many rows to stay on the same commit. Compute the target as an + // absolute index now, on the current list. + targetIdx := self.context().GetSelectedLineIdx() - selectionOffset + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) - self.context().MoveSelectedLine(-selectionOffset) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{}) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + // Set the selection in Then so it lands in the same frame as the + // refreshed commit list. It has to be an absolute index: the new + // list is shorter, so a relative move from the (clamped) old index + // could overshoot. PostRefreshUpdate repaints the moved selection. + Then: func() error { + if err == nil { + self.context().SetSelectedLineIdx(targetIdx) + self.c.PostRefreshUpdate(self.context()) + } + return nil + }, + }) }) } diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index fe28ca603..f3e26e303 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -223,12 +223,18 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.RebasingStatus, func() error { - commitIndex := self.getPatchCommitIndex() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() + return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) - err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) - self.c.Helpers().PatchBuilding.Escape() - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( + err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) + // Escape pops the patch-building context, so run it on the UI thread + // before the refresh below. + _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + self.c.Helpers().PatchBuilding.Escape() + return nil + }) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{}) }) } From d802cbdddf43868435fd6df997ba0fae214b41bd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:00:53 +0200 Subject: [PATCH 51/94] Block input during the worker commit-surgery ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit, quick-start rebase, drop, reword, squash, fixup, amend (including the amend-attribute author operations) and discard-file-from-commit all run a rebase on a worker. A key pressed while one is in flight could act on a stale commit or todo — pressing e to start an interactive rebase, then up+d before it finishes, is the motivating example. Switch them from WithWaitingStatus to WithWaitingStatusBlockingInput so input is held and replayed against the post-operation state, matching the commit-surgery ops that were already sync. Left alone: the custom-patch move/delete/pull-into-commit rebases (no need to block input while building and applying a patch), the loading-more-commits and patch-building toggle spinners (no rebase to disrupt), and fetches and other non-surgery operations where blocking navigation would only get in the way. --- .../controllers/commits_files_controller.go | 2 +- .../controllers/local_commits_controller.go | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 14e1c1a50..d129b3f90 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -339,7 +339,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN HandleConfirm: func() error { commits := self.c.Model().Commits selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1234b80ec..2da502b79 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -342,7 +342,7 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, HandleConfirm: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) @@ -366,7 +366,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) @@ -379,7 +379,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) @@ -490,7 +490,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str self.c.Tr.RewordingStatus, nil, nil) } - return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err @@ -576,7 +576,7 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start if !isMerge { self.selectRebaseResultCommit(startIdx) } - return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(commits, startIdx) @@ -601,7 +601,7 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{BatchUIUpdates: true}) @@ -623,7 +623,7 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error { func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( @@ -823,7 +823,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) @@ -885,7 +885,7 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en } func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err @@ -901,7 +901,7 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err @@ -921,7 +921,7 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err From a324f8aef181bd264a1c048ffd280619f6fe6e2d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:05:11 +0200 Subject: [PATCH 52/94] Drop the now-unused UI-thread CheckMergeOrRebase path With the last synchronous commit-surgery callers moved to workers, nothing runs CheckMergeOrRebase on the UI thread anymore, so CheckMergeOrRebaseWithRefreshOptionsFromUIThread has no callers. Remove it and fold the shared checkMergeOrRebaseImpl back into CheckMergeOrRebaseWithRefreshOptions, which is now always on a worker. The runAction closure loses its calledFromWorker parameter for the same reason. genericMergeCommandImpl keeps its calledFromWorker flag: the merge/rebase-continue subprocess path still runs on the UI thread when invoked straight from the menu, and on a worker for the recursive auto-skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 7f1a31188..a8696a21c 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -89,11 +89,11 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { // non-subprocess path runs on a worker with a waiting status. // // showWaitingStatus is false only for the recursive auto-skip in -// checkMergeOrRebaseImpl: that call already runs on the caller's thread (the -// worker of the enclosing waiting status, or the UI thread for the synchronous -// callers), so it must not spin up a second one. calledFromWorker says which of -// those two the body runs on, so the post-action refresh picks Refresh vs -// RefreshFromWorker correctly. +// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it +// must not spin up a second waiting status. calledFromWorker is used only by the +// subprocess path below: it's true for that recursive worker skip and false for +// genericMergeCommand's UI-thread invocation, so the post-action refresh picks +// RefreshFromWorker vs Refresh correctly. func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error { status := self.c.Git().Status.WorkingTreeState() @@ -139,21 +139,23 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa return err } - runAction := func(calledFromWorker bool) error { + // runAction always ends up on a worker: either the waiting status below spins + // one up, or we're the recursive auto-skip reached from + // CheckMergeOrRebaseWithRefreshOptions, which already runs on one. + runAction := func() error { result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.checkMergeOrRebaseImpl(result, + return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{ CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), - }, calledFromWorker) + }) } if showWaitingStatus { return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { - // The waiting status ran runAction on a worker. - return runAction(true) + return runAction() }) } - return runAction(calledFromWorker) + return runAction() } // commitSelectionAfterMerge maps whether a merge/rebase/pull created a new @@ -209,33 +211,19 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() { } // CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase -// step and refreshes. It's for callers running on a worker (the -// WithWaitingStatus / WithInlineStatus handlers), which is the large majority; -// UI-thread callers use CheckMergeOrRebaseWithRefreshOptionsFromUIThread. +// step and refreshes. It always runs on a worker (the WithWaitingStatus / +// WithWaitingStatusBlockingInput / WithInlineStatus handlers). func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { - return self.checkMergeOrRebaseImpl(result, refreshOptions, true) -} - -// CheckMergeOrRebaseWithRefreshOptionsFromUIThread is like -// CheckMergeOrRebaseWithRefreshOptions, but for the callers that run the -// merge/rebase synchronously on the UI thread (the WithWaitingStatusSync -// move/revert/squash-fixups/cherry-pick-paste/patch-discard handlers, kept sync -// so rapid key presses batch) rather than on a worker. -func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result error, refreshOptions types.RefreshOptions) error { - return self.checkMergeOrRebaseImpl(result, refreshOptions, false) -} - -func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOptions types.RefreshOptions, calledFromWorker bool) error { - self.refreshAfterMergeOrRebase(refreshOptions, calledFromWorker) + self.refreshAfterMergeOrRebase(refreshOptions, true) self.RecordWhetherMergeOrRebaseStartedInLazygit() if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil @@ -245,8 +233,8 @@ func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOp // refreshAfterMergeOrRebase issues the post-action refresh on the entry point // that matches the thread the merge/rebase ran on: RefreshFromWorker for the -// worker callers, Refresh for the ones that stayed synchronously on the UI -// thread. +// worker callers, Refresh for the merge/rebase-continue subprocess path that +// stays on the UI thread. func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) { if calledFromWorker { self.c.RefreshFromWorker(refreshOptions) From a247dfd76d63c235f4e94dfc5ee31e57d0e4a07e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:01:46 +0200 Subject: [PATCH 53/94] Retire WithWaitingStatusSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing calls it anymore now that the commit-surgery operations run on a worker with input blocked. Remove the helper, its bespoke synchronous spinner loop (renderAppStatusSync/setAppStatusContent), the popup-handler plumbing, and the interface method. That loop was also the only thing suppressing the yellow "Rebasing" mode indicator (and its reset button) while lazygit drives a rebase itself. Move that suppression to WithWaitingStatusBlockingInput so it applies to every input-blocking commit-surgery op — including the ones that already ran on a worker (edit, drop, and so on) and previously let the indicator flash on mid-operation. It's cleared after the refresh, so an operation that legitimately leaves a rebase in progress still shows the mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/app_status_helper.go | 81 +++---------------- pkg/gui/gui.go | 3 - pkg/gui/popup/popup_handler.go | 7 -- pkg/gui/types/common.go | 1 - 4 files changed, 10 insertions(+), 82 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index a366909f6..69daa7d7a 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -98,31 +98,24 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. // between. func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { self.c.GocuiGui().BeginBlockingEvents() + // Hide the rebasing-mode indicator (and its reset button) while we drive the + // rebase ourselves; it reflects the transient on-disk state and would + // otherwise flash on for the duration of the operation. + self.modeHelper.SetSuppressRebasingMode(true) self.c.OnWorker(func(task gocui.Task) error { - // End the block once the operation and its refresh have applied their UI - // updates: OnUIThread queues this after the refresh's model bounces and - // Then (which RefreshFromWorker has already enqueued by the time f - // returns), so the replayed keys act on the refreshed state. + // End the block and restore the mode indicator once the operation and its + // refresh have applied their UI updates: OnUIThread queues this after the + // refresh's model bounces and Then (which RefreshFromWorker has already + // enqueued by the time f returns), so the replayed keys act on the + // refreshed state and any resulting rebase state shows correctly. defer self.c.OnUIThread(func() error { + self.modeHelper.SetSuppressRebasingMode(false) return self.c.GocuiGui().EndBlockingEvents() }) return self.WithWaitingStatusImpl(message, f, task, false) }) } -func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { - self.c.PauseBackgroundRefreshes(true) - defer self.c.PauseBackgroundRefreshes(false) - - return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error { - stop := make(chan struct{}) - defer func() { close(stop) }() - self.renderAppStatusSync(stop) - - return f() - }) -} - func (self *AppStatusHelper) HasStatus() bool { return self.statusMgr().HasStatus() } @@ -174,57 +167,3 @@ func (self *AppStatusHelper) renderAppStatus(background bool) { return nil }) } - -func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { - go func() { - ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) - defer ticker.Stop() - - // Write the status into the view before the first layout below, so that - // layout (which sizes the bottom line based on the actual content of the - // AppStatus view) leaves room for it and it shows right away. The ticker - // only updates the spinner frame using ForceFlushViewsContentOnly, so this - // doesn't re-layout. - self.setAppStatusContent() - - // Forcing a re-layout and redraw after we added the waiting status; - // this is needed in case the gui.showBottomLine config is set to false, - // to make sure the bottom line appears. It's also useful for redrawing - // once after each of several consecutive keypresses, e.g. pressing - // ctrl-j to move a commit down several steps. - _ = self.c.GocuiGui().ForceLayoutAndRedraw() - - self.modeHelper.SetSuppressRebasingMode(true) - defer func() { self.modeHelper.SetSuppressRebasingMode(false) }() - - outer: - for { - select { - case <-ticker.C: - self.setAppStatusContent() - // Redraw all views of the bottom line: - bottomLineViews := []*gocui.View{ - self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information, - self.c.Views().StatusSpacer1, self.c.Views().StatusSpacer2, - } - _ = self.c.GocuiGui().ForceFlushViewsContentOnly(bottomLineViews) - case <-stop: - // Clear the status from the view and re-layout, otherwise the - // stale content would keep layout reserving room for it forever. - // The UI thread is free again at this point, so we go through - // OnUIThread like the async renderAppStatus does. - self.c.OnUIThread(func() error { - self.c.SetViewContent(self.c.Views().AppStatus, "") - return nil - }) - break outer - } - } - }() -} - -func (self *AppStatusHelper) setAppStatusContent() { - appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - self.c.Views().AppStatus.FgColor = color - self.c.SetViewContent(self.c.Views().AppStatus, appStatus) -} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a6baaf373..ce70cb2d5 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -823,9 +823,6 @@ func NewGui( func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) }, - func(message string, f func() error) error { - return gui.helpers.AppStatus.WithWaitingStatusSync(message, f) - }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Prompt.TextArea.GetContent() }, func() bool { return gui.c.InDemo() }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index 23084f9ce..7c15c56ea 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -20,7 +20,6 @@ type PopupHandler struct { createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) - withWaitingStatusSyncFn func(message string, f func() error) error toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool @@ -37,7 +36,6 @@ func NewPopupHandler( createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), - withWaitingStatusSyncFn func(message string, f func() error) error, toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, @@ -51,7 +49,6 @@ func NewPopupHandler( createMenuFn: createMenuFn, withWaitingStatusFn: withWaitingStatusFn, withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn, - withWaitingStatusSyncFn: withWaitingStatusSyncFn, toastFn: toastFn, getPromptInputFn: getPromptInputFn, inDemo: inDemo, @@ -84,10 +81,6 @@ func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func( return nil } -func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error { - return self.withWaitingStatusSyncFn(message, f) -} - func (self *PopupHandler) ErrorHandler(err error) error { var notHandledError *types.ErrKeybindingNotHandled if errors.As(err, ¬HandledError) { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 964143c5a..5a256b434 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -162,7 +162,6 @@ type IPopupHandler interface { Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error - WithWaitingStatusSync(message string, f func() error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) From 9bb9fc8933315e72f096fd4141f602e89feb3a22 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:11:24 +0200 Subject: [PATCH 54/94] Run all refresh scopes on plain goroutines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two branches of the `refresh` closure ran the scope function identically; they differed only in that the UI-thread path registered each scope as its own gocui task while the worker/demo path used a bare goroutine (and only the latter logged per-scope timing). Those per-scope tasks were redundant. performRefresh always runs under a task that stays busy until the wg.Wait in waitAndFinalize joins every scope goroutine: the calling worker's own task when called from a worker, or the waitAndFinalize worker task when called from the UI thread — and that task is created (busy) before the triggering event's task goes Done, so there is no window in which nothing is busy. Repo-switch safety and the integration-test idle signal are therefore already covered without giving each scope its own task. Collapsing to the single goroutine path also means the timing log now fires for UI-thread refreshes too, not just worker ones. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fdb526549..08a2e615c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -234,27 +234,19 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg := sync.WaitGroup{} refresh := func(name string, f func()) { wg.Add(1) - - // A refresh issued from the UI thread must not block it, so its scopes - // run as their own worker tasks and the caller returns immediately (the - // finishing step below is dispatched to a worker too). A refresh issued - // from a worker blocks that worker instead, running its scopes as plain - // goroutines that it joins. In a demo we always take the blocking path - // so everything updates in a single, deterministic frame. - if !self.c.InDemo() && !calledFromWorker { - self.onWorker(env.background, func(t gocui.Task) error { - defer wg.Done() - f() - return nil - }) - } else { - go utils.Safe(func() { - t := time.Now() - defer wg.Done() - f() - self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) - }) - } + // 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{} From 2f60280eb694a79921dd2eb980aa4ab519789cd1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:21:45 +0200 Subject: [PATCH 55/94] Log Refresh timing information for both sync/async For async refreshes (from UI thread) it would only log the time it took to schedule the refreshXxx calls, which is not useful. --- pkg/gui/controllers/helpers/refresh_helper.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 08a2e615c..5fa5ffbec 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -142,10 +142,7 @@ func (self *refreshBounceBatch) close() []func() { } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { - t := time.Now() - defer func() { - self.c.Log.Infof("Refresh took %s", time.Since(t)) - }() + 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. @@ -432,6 +429,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // still pre-refresh. self.onUIThread(env.background, options.Then) } + + self.c.Log.Infof("Refresh took %s", time.Since(startTime)) } // waitAndFinalize blocks until every scope is done. Run it inline when we're From 4ff161b48d94b077920ca9a8b8d0f971072a021b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:28:59 +0200 Subject: [PATCH 56/94] Don't wait for pull requests to be fetched in refresh Fetching pull requests can take a long time, and we don't want to delay the refresh by it; in particular, for a WithWaitingStatusBlockingInput we want the UI thread to be unblocked again while pull requests are still fetching in the background. This is similar to how we fetch the behind values for branches in BranchLoader; this will update the UI without much flicker when done, and doesn't have to block anything. --- pkg/gui/controllers/helpers/refresh_helper.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 5fa5ffbec..768e7a618 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -365,13 +365,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PULL_REQUESTS) { - refresh("pull requests", func() { + self.onWorker(env.background, 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, env) + self.c.Log.Infof("refreshed pull requests in %s", time.Since(t)) + return nil }) } From 303372d91789b0dd0614033b58122fa450ffa2bb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 07:53:14 +0200 Subject: [PATCH 57/94] Perform string-task view updates on the UI thread The closures that render static content to a main view (newStringTask and friends) ran on the ViewBufferManager's task goroutine, calling SetViewContent/SetOrigin/ResetViewOrigin directly on the view. Those touch view state (the line buffer, hover cells, the origin) that the UI thread concurrently reads and mutates while laying out and drawing, so they raced it -- e.g. a string task's SetContent clearing the view's lines while the UI thread's CopyContent read them, or its SetOrigin racing the layout's OriginY read. Bounce the whole closure onto the UI thread instead, so the view is only touched there. The bounce blocks (OnUIThreadAndWaitBackground) so the task still completes only once the content has actually been rendered, which the integration-test idle detection relies on; the background variant keeps it from counting towards the app being busy, matching the existing treatment of view rendering as work that must not block a repo switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index acad4fb75..808a4341d 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -59,8 +59,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -74,9 +76,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - view.SetOrigin(originX, originY) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + view.SetOrigin(originX, originY) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -90,9 +94,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.ResetViewOrigin(view) - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.ResetViewOrigin(view) + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, key); err != nil { From 99c1bcbf23ee9f08e8a694d53af08bc1b59394b7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:04:08 +0200 Subject: [PATCH 58/94] Reset the view origin for a new task on the UI thread When a task renders different content to a view (a new task key), the view's scroll origin is reset to the top via onNewKey. That ran on the task's own goroutine, racing the UI thread, which reads the origin (OriginY) while laying out and drawing the view -- the single largest source of view-render data races. Give ViewBufferManager a bounce primitive (onUIThread) that runs a function on the UI thread and waits for it, and reset the origin through it. This is the first use of the primitive; subsequent commits route the rest of the task's view mutations through it too, so that the view is only ever touched on the UI thread. It runs as background work (OnUIThreadAndWaitBackground) so rendering doesn't count towards the app being busy, matching how the render's gocui task is already created. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 3 +++ pkg/tasks/tasks.go | 30 ++++++++++++++++++++++++------ pkg/tasks/tasks_test.go | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 808a4341d..9f9488048 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -156,6 +156,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { // otherwise make the switch that handler triggers refuse itself. return gui.c.GocuiGui().NewBackgroundTask() }, + // Rendering is background work too (see above), so the view mutations + // it bounces onto the UI thread mustn't count towards being busy. + gui.g.OnUIThreadAndWaitBackground, ) gui.viewBufferManagerMap[view.Name()] = manager } diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 9da12d40b..c8f5b8244 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -74,6 +74,12 @@ type ViewBufferManager struct { // whereas the tasks in this file are about rendering content to a view. newGocuiTask func() gocui.Task + // Runs f on the UI thread and blocks until it has completed. All mutations + // of the view happen through this, so that the view is only ever touched on + // the UI thread (where it is also laid out and drawn), never on the task's + // own goroutine. + onUIThread func(f func() error) error + // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we @@ -110,6 +116,7 @@ func NewViewBufferManager( onEndOfInput func(), onNewKey func(), newGocuiTask func() gocui.Task, + onUIThread func(f func() error) error, ) *ViewBufferManager { return &ViewBufferManager{ Log: log, @@ -120,6 +127,7 @@ func NewViewBufferManager( readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, + onUIThread: onUIThread, } } @@ -460,21 +468,31 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.taskIDMutex.Lock() // Bail out before touching shared view state if a newer task has - // already been queued: if we ran onNewKey here we'd reset the view - // for a task that's about to exit, potentially wiping output the - // winning task has already written. + // already been queued: if we reset the view here we'd do it for a task + // that's about to exit, potentially wiping output the winning task has + // already written. if taskID < self.newTaskID { self.taskIDMutex.Unlock() return } - if self.GetTaskKey() != key && self.onNewKey != nil { - self.onNewKey() - } + resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil self.taskKey = key self.taskIDMutex.Unlock() + if resetOrigin { + // onNewKey resets the view's scroll origin, which is view state the + // UI thread reads while laying out and drawing, so do it there. This + // must happen after releasing taskIDMutex: it blocks until the UI + // thread runs it, and a NewTask call on the UI thread takes + // taskIDMutex, so holding it here would deadlock. + _ = self.onUIThread(func() error { + self.onNewKey() + return nil + }) + } + self.waitingMutex.Lock() // Re-check staleness after acquiring waitingMutex: a newer task diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index c025e8e16..2cea139e8 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -39,6 +39,8 @@ func TestNewCmdTaskInstantStop(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -104,6 +106,8 @@ func TestNewCmdTask(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -237,6 +241,8 @@ func TestNewCmdTaskRefresh(t *testing.T) { func() {}, func() {}, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) From 40868a93895d8920cb89dc084644e9344d403d4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:08:47 +0200 Subject: [PATCH 59/94] Hold the ViewBufferManager readLines channel in an atomic The readLines channel, by which a running task is told to read more lines as the user scrolls, is swapped out as tasks start and finish. It was a plain field written from the task goroutines (when a task starts, ends, or is replaced) and read from the UI thread in ReadLines/ ReadToEnd, so those accesses raced -- a longstanding data race (and a plausible cause of the occasional "main view stops updating" hang, since a torn read there could drop a scroll's read request). Make the field an atomic.Pointer and give the running task a captured local copy of the channel for its own send/receive, so the field itself is only ever loaded/stored atomically. No lock is involved, so there's nothing to untangle later. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c8f5b8244..50a94a15c 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -59,9 +60,13 @@ type ViewBufferManager struct { taskIDMutex deadlock.Mutex Log *logrus.Entry newTaskID int - readLines chan LinesToRead - taskKey string - onNewKey func() + // The channel by which the currently-running task is told to read more + // lines (e.g. as the user scrolls). Held in an atomic because it's swapped + // out as tasks come and go while ReadLines/ReadToEnd read it from the UI + // thread; nil when no task is running. + readLines atomic.Pointer[chan LinesToRead] + taskKey string + onNewKey func() // beforeStart is the function that is called before starting a new task beforeStart func() @@ -124,7 +129,6 @@ func NewViewBufferManager( beforeStart: beforeStart, refreshView: refreshView, onEndOfInput: onEndOfInput, - readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, onUIThread: onUIThread, @@ -136,17 +140,19 @@ func NewViewBufferManager( // (e.g. as the user scrolls down, back up, and down again) don't re-read lines // that have already been read: the task only ever reads the shortfall. func (self *ViewBufferManager) ReadLines(totalLines int) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} + readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} }) } } func (self *ViewBufferManager) ReadToEnd(then func()) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} + readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} }) } else if then != nil { then() @@ -220,7 +226,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix loadingMutex := deadlock.Mutex{} - self.readLines = make(chan LinesToRead, 1024) + readLines := make(chan LinesToRead, 1024) + self.readLines.Store(&readLines) scanner := bufio.NewScanner(r) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) @@ -312,7 +319,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix select { case <-opts.Stop: break outer - case linesToRead := <-self.readLines: + case linesToRead := <-readLines: callThen := func() { if linesToRead.Then != nil { linesToRead.Then() @@ -367,7 +374,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } } - self.readLines = nil + self.readLines.Store(nil) refreshViewIfStale() @@ -391,7 +398,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix close(lineWrittenChan) }) - self.readLines <- linesToRead + readLines <- linesToRead <-done @@ -509,7 +516,7 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.stopCurrentTask() } - self.readLines = nil + self.readLines.Store(nil) stop := make(chan struct{}) notifyStopped := make(chan struct{}) From e75688c101ce3e6b2a8c668a182f0e8c6c7aa1e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:39:00 +0200 Subject: [PATCH 60/94] Make the ViewBufferManager throttle flag atomic The throttle flag is set from the goroutine that watches a task for being stopped, and read when the next task starts up -- two different goroutines, so the plain bool field was a data race. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 50a94a15c..d58be3a92 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -88,8 +88,9 @@ type ViewBufferManager struct { // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we - // want to throttle the spawning of processes. - throttle bool + // want to throttle the spawning of processes. Atomic because it's set + // from one task's stop goroutine and read when the next task starts. + throttle atomic.Bool } type LinesToRead struct { @@ -177,7 +178,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix onFirstPageShown() } - if self.throttle { + if self.throttle.Load() { self.Log.Info("throttling task") time.Sleep(THROTTLE_TIME) } @@ -200,13 +201,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix case <-done: // The command finished and did not have to be preemptively stopped before the next command. // No need to throttle. - self.throttle = false + self.throttle.Store(false) case <-opts.Stop: // we use the time it took to start the program as a way of checking if things // are running slow at the moment. This is admittedly a crude estimate, but // the point is that we only want to throttle when things are running slow // and the user is flicking through a bunch of items. - self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD + self.throttle.Store(time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD) // Kill the still-running command. The only reason to do this is to save CPU usage // when flicking through several very long diffs when diff.algorithm = histogram is From f6eaed8cd4f2298fad4126ff56df77b71daed730 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:00:49 +0200 Subject: [PATCH 61/94] Snapshot the view width for command-task rendering on the UI thread A command task streams its output into a view from its own goroutine. To track soft-wraps (so cursor-positioning escapes from a pager land on the right line) the write path read the view's live InnerWidth, and the pty setup read its InnerSize -- both off the UI thread, racing the UI thread mutating the view's dimensions during layout. Capture the width on the UI thread instead and hand it to the task: the escape interpreter keeps a screenColMax it reads from, seeded in NewView and refreshed per render via View.SetContentWidth (called from newCmdTask/newPtyTask before the task's goroutine starts), and the pty size is computed in the after-layout callback rather than in the task's start func. The view's dimensions stay UI-thread-only; the task uses the snapshot rather than reading them live. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/escape.go | 14 +++++++++++--- pkg/gocui/view.go | 13 ++++++++++++- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 9 +++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index ad862a596..7f3de9e6e 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -34,6 +34,14 @@ type escapeInterpreter struct { // modelled — we don't track the col argument of CUPs, and most // pager-style emitters use col 1 anyway. screenRow, screenCol int + + // The screen width that soft-wraps are counted against (see + // notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on + // the UI thread (in NewView, and refreshed per render via + // View.SetContentWidth), rather than read live from the view's dimensions: + // a view's output is written from a task goroutine, and reading the live + // dimensions there would race the UI thread updating them during layout. + screenColMax int } type ( @@ -175,8 +183,8 @@ func (ei *escapeInterpreter) notifyColumnReset() { // columns; if that crosses the right edge of a `screenColMax`-wide pty // screen, the corresponding number of soft-wraps are added to screenRow // so subsequent CUPs land on the right line. -func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { - if screenColMax <= 0 { +func (ei *escapeInterpreter) notifyCellsWritten(width int) { + if ei.screenColMax <= 0 { return } // One column at a time: matches ConPTY's "pending wrap" semantics @@ -185,7 +193,7 @@ func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { // columns rather than doing the math in one shot so wide cells on a // row boundary still wrap cleanly. for range width { - if ei.screenCol > screenColMax { + if ei.screenCol > ei.screenColMax { ei.screenRow++ ei.screenCol = 1 } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index e4c5a5f98..ae8aea880 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -536,9 +536,20 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault v.InactiveViewSelBgColor = ColorDefault v.TitleColor, v.FrameColor = ColorDefault, ColorDefault + v.ei.screenColMax = v.InnerWidth() return v } +// SetContentWidth tells the view the screen width that content written to it +// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten). +// Callers pass the view's InnerWidth; it's a separate call, made on the UI +// thread when a render starts, so that the task goroutine that streams the +// content can consult this snapshot instead of reading the view's live +// dimensions (which the UI thread mutates during layout). +func (v *View) SetContentWidth(width int) { + v.ei.screenColMax = width +} + // Dimensions returns the dimensions of the View func (v *View) Dimensions() (int, int, int, int) { return v.x0, v.y0, v.x1, v.y1 @@ -907,7 +918,7 @@ func (v *View) write(p []byte) { for _, c := range cells { totalWidth += c.width } - v.ei.notifyCellsWritten(totalWidth, v.InnerWidth()) + v.ei.notifyCellsWritten(totalWidth) } } } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 1a774fc3d..d4f739c6d 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -93,9 +93,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Size the pty from the view's dimensions here, on the UI thread; the + // start func below runs on the task's goroutine, which must not read the + // view's live dimensions while the UI thread is laying it out. + cols, rows := gui.desiredPtySize(view) + var p oscommands.Pty start := func() (tasks.Cmd, io.Reader) { - cols, rows := gui.desiredPtySize(view) + // The pty (and pager) wrap to this width; apply it here, on the + // task's goroutine once the previous task has stopped, so it doesn't + // race that task's writes (see View.SetContentWidth). + view.SetContentWidth(width) + sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 9f9488048..27aacf58b 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Snapshot the view width here, on the UI thread, so the task goroutine + // doesn't read the view's live dimensions while it streams output. It's + // applied inside start() below rather than now, because start() runs once + // the previous task has stopped -- applying it here would race that task's + // still-running writes (see View.SetContentWidth). + contentWidth := view.InnerWidth() + var r io.ReadCloser start := func() (tasks.Cmd, io.Reader) { + view.SetContentWidth(contentWidth) + var err error r, err = cmd.StdoutPipe() if err != nil { From 65cb439076b665d458609f7f3a769786200029e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:01 +0200 Subject: [PATCH 62/94] Take the write mutex when clearing view lines and reading the buffer A view's line buffer, its viewLines/tainted flags, and its hover state are all written from the command-task goroutine (under writeMutex) as it renders. But three accessors reached that same state from the UI thread without the lock: SetView and the GUI-resize path cleared a view's lines directly, viewsToRedrawContentOnly read the tainted flag, and Buffer read the line buffer. Each raced a rendering task. Guard them with writeMutex, matching the view's other buffer accessors. These are reads/clears of state writeMutex already protects, not new callers of it -- the view's geometry stays outside the mutex. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 6 +++--- pkg/gocui/view.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 700c2b54c..936e1a310 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -385,7 +385,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er v.y1 = y1 if sizeChanged { - v.clearViewLines() + v.ClearViewLines() if v.Editable { cursorX, cursorY := v.TextArea.GetCursorXY() @@ -1461,7 +1461,7 @@ func (g *Gui) flush() error { // if GUI's size has changed, we need to redraw all views if maxX != g.maxX || maxY != g.maxY { for _, v := range g.views { - v.clearViewLines() + v.ClearViewLines() } } g.maxX, g.maxY = maxX, maxY @@ -1500,7 +1500,7 @@ func viewsToRedrawContentOnly(views []*View) []*View { redrawIndexes := set.New[int]() for i, v := range views { - if !v.tainted && !redrawIndexes.Includes(i) { + if !v.IsTainted() && !redrawIndexes.Includes(i) { continue } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ae8aea880..39f2d78a9 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -214,6 +214,16 @@ func (v *View) clearViewLines() { v.clearHover() } +// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on +// the UI thread (the layout pass) that touch a view whose content a task +// goroutine may be writing concurrently: viewLines/tainted/hover are all +// buffer state that writeMutex protects. +func (v *View) ClearViewLines() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + v.clearViewLines() +} + type searcher struct { searchString string searchPositions []SearchPosition @@ -1287,6 +1297,8 @@ func (v *View) updateSearchPositions() { // IsTainted tells us if the view is tainted func (v *View) IsTainted() bool { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() return v.tainted } @@ -1535,6 +1547,9 @@ func (v *View) BufferLines() []string { // Buffer returns a string with the contents of the view's internal // buffer. func (v *View) Buffer() string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + return linesToString(v.lines) } From eda215133072bfd135dae7c9001711358221e4b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:09 +0200 Subject: [PATCH 63/94] Handle a command task's end-of-input on the UI thread When a command task reaches EOF it runs onEndOfInput, which reads the view's line height (and thus its dimensions) to decide whether to scroll, sets the view's origin, and flushes stale cells. Reading the dimensions and setting the origin are UI-thread-only, but this ran on the task's own goroutine, racing the UI thread. Bounce onEndOfInput onto the UI thread, as we already do for the new-task origin reset. It's once per render, so it doesn't add the per-line UI-thread churn that streaming the content would. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index d58be3a92..3a964c838 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -353,8 +353,14 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content - self.onEndOfInput() + // so we're at the EOF and can flush the stale content. + // onEndOfInput reads the view's dimensions (to decide + // whether to scroll) and sets the origin, both of which + // are UI-thread-only, so run it there. + _ = self.onUIThread(func() error { + self.onEndOfInput() + return nil + }) callThen() break outer } From 9754a77b64439149a455ba91679ffe88efbebb3b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:08:07 +0200 Subject: [PATCH 64/94] Create popups and menus on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising a popup or menu pushes a context and mutates the popup views, so it must happen on the UI thread. But it can be triggered from a worker goroutine — for example a WithWaitingStatus handler that hits a merge conflict and calls PromptForConflictHandling, or a worker that shows a confirmation — where it raced the UI thread's layout and draw code. Bounce the creation onto the UI thread at the one point where the popup and menu producers are injected into the popup handler, so every caller stays oblivious to the threading. For a caller that is already on the UI thread this adds no delay: the main loop drains the enqueued closure in the same event-processing cycle, before it draws. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index ce70cb2d5..993798e42 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -812,13 +812,25 @@ func NewGui( gui.PopupHandler = popup.NewPopupHandler( cmn, + // Raising a popup or menu pushes a context and mutates the popup views, + // and it can be triggered from a worker goroutine (e.g. a + // WithWaitingStatus handler that hits a merge conflict and asks the user + // how to proceed). Bounce the creation onto the UI thread so it can't + // race the layout/draw code. Doing it here, at the one point where these + // producers are injected, keeps every caller oblivious to the threading. func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + gui.onUIThread(func() error { + gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + return nil + }) }, func() error { gui.c.Refresh(types.RefreshOptions{}); return nil }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, - gui.createMenu, + func(opts types.CreateMenuOptions) error { + gui.onUIThread(func() error { return gui.createMenu(opts) }) + return nil + }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) From 435e02efa83e8659e3befd2082852bc4df415a00 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:44:54 +0200 Subject: [PATCH 65/94] Remove the now-dead PopupMutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMutex guarded CurrentPopupOpts against a popup being created on a worker goroutine while the UI thread deactivated it, or reset it on a repo switch. Now that popup and menu creation is bounced onto the UI thread, every access to CurrentPopupOpts — create, deactivate, and the reset-on-switch (which already runs on the UI thread) — happens on the one goroutine, so the mutex protects nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/confirmation_helper.go | 7 ------- pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 1 - 3 files changed, 10 deletions(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 3663cd4ea..beffeb5e2 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -77,9 +77,7 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction( } func (self *ConfirmationHelper) DeactivateConfirmation() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Confirmation.Visible = false @@ -87,9 +85,7 @@ func (self *ConfirmationHelper) DeactivateConfirmation() { } func (self *ConfirmationHelper) DeactivatePrompt() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Prompt.Visible = false self.c.Views().Suggestions.Visible = false @@ -188,9 +184,6 @@ func characterForMask(mask bool) string { } func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - self.c.Mutexes().PopupMutex.Lock() - defer self.c.Mutexes().PopupMutex.Unlock() - _, cancel := goContext.WithCancel(ctx) // we don't allow interruptions of non-loader popups in case we get stuck somehow diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 993798e42..9b592ffae 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -620,9 +620,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { // setting this to nil so we don't get stuck based on a popup that was // previously opened - gui.Mutexes.PopupMutex.Lock() gui.State.CurrentPopupOpts = nil - gui.Mutexes.PopupMutex.Unlock() return gui.c.Context().Current() } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 5a256b434..6e7f72541 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -358,7 +358,6 @@ type Model struct { type Mutexes struct { SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex PtyMutex deadlock.Mutex } From 59ed1517bc1b3d42e91a5f65fc77ab427edbb5aa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:13 +0200 Subject: [PATCH 66/94] Wait for the event loop to exit in integration tests The test harness enqueued ErrQuit after a test finished, waited for the program to go idle, then slept a fixed second and declared "gocui should have already exited" if it hadn't. That fixed grace is fragile: under the race detector the shutdown legitimately takes longer than a second, so nearly every test failed with that message even though nothing was wrong. Wait for the main loop to actually return instead. gocui now closes a loopExited channel when MainLoop exits, and the harness blocks on it; the existing 40s watchdog still fails a test whose loop genuinely never quits. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 12 ++++++++++++ pkg/gui/test_mode.go | 7 ++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 936e1a310..dbef57b59 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -148,6 +148,10 @@ type Gui struct { maxX, maxY int outputMode OutputMode stop chan struct{} + // loopExited is closed when MainLoop returns, so callers (e.g. the + // integration-test harness) can wait for the event loop to actually finish + // rather than polling or sleeping a fixed interval. + loopExited chan struct{} // BgColor and FgColor allow to configure the background and foreground // colors of the GUI. @@ -260,6 +264,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.outputMode = opts.OutputMode g.stop = make(chan struct{}) + g.loopExited = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) g.userEvents = newUserEventQueue() @@ -348,6 +353,11 @@ func (g *Gui) Close() { Screen.Fini() } +// LoopExited returns a channel that is closed once MainLoop has returned. +func (g *Gui) LoopExited() <-chan struct{} { + return g.loopExited +} + // Size returns the terminal's size. func (g *Gui) Size() (x, y int) { return g.maxX, g.maxY @@ -965,6 +975,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + defer close(g.loopExited) + g.uiThreadID.Store(goid.Get()) go func() { diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 2d5958fbb..d6893c92d 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -40,11 +40,8 @@ func (gui *Gui) handleTestMode() { return gocui.ErrQuit }) - waitUntilIdle() - - time.Sleep(time.Second * 1) - - log.Fatal("gocui should have already exited") + // Wait for the event loop to actually exit. + <-gui.g.LoopExited() }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { From 33b8d497c22f131829b1b3181b9b19ce3800a80c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:22 +0200 Subject: [PATCH 67/94] Guard the patch builder against concurrent access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-patch git operations (move/pull/delete patch, and their rebase continuations) run on worker goroutines and call PatchBuilder.Reset when they've consumed the patch, clearing To and the fileInfoMap. Meanwhile the UI thread reads that state every layout — the options bar and the mode indicator both call Active() — so the reset raced the render. Add a mutex. The map's entries are only ever touched on the UI thread, so the lock only has to serialize the To field and the fileInfoMap pointer: readers snapshot the pointer under the lock and iterate the local, and getFileInfo drops the lock across its git diff I/O rather than holding it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/patch/patch_builder.go | 63 ++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index b730d9f62..0d5ca34f8 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/generics/maps" "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" ) @@ -50,6 +51,13 @@ type PatchBuilder struct { fileInfoMap map[string]*fileInfo Log *logrus.Entry + // mutex guards the fields that a git worker can mutate (via Reset, at the + // end of a patch-consuming operation) while the UI thread reads them to + // render — chiefly To and the fileInfoMap pointer. The map's *entries* are + // only ever touched on the UI thread, so we only hold the lock long enough + // to read or swap the fields, never across the git I/O in getFileInfo. + mutex deadlock.Mutex + // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc } @@ -62,6 +70,9 @@ func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBui } func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = to p.From = from p.reverse = reverse @@ -69,10 +80,21 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.fileInfoMap = map[string]*fileInfo{} } +// snapshotFileInfoMap returns the current fileInfoMap under the lock. The map's +// entries are only mutated on the UI thread, so callers can read the returned +// map without holding the lock; the lock only serializes the pointer swap that +// Reset/Start do (potentially from a git worker) against these reads. +func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.fileInfoMap +} + func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { var patch strings.Builder - for filename, info := range p.fileInfoMap { + for filename, info := range p.snapshotFileInfoMap() { if info.mode == UNSELECTED { continue } @@ -130,12 +152,17 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { } func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) { - info, ok := p.fileInfoMap[filename] + p.mutex.Lock() + fileInfoMap := p.fileInfoMap + from, to, reverse := p.From, p.To, p.reverse + p.mutex.Unlock() + + info, ok := fileInfoMap[filename] if ok { return info, nil } - diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true) + diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true) if err != nil { return nil, err } @@ -145,7 +172,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI previousPath: previousPath, } - p.fileInfoMap[filename] = info + fileInfoMap[filename] = info return info, nil } @@ -220,14 +247,16 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { } func (p *PatchBuilder) renderEachFilePatch(plain bool) []string { + fileInfoMap := p.snapshotFileInfoMap() + // sort files by name then iterate through and render each patch - filenames := maps.Keys(p.fileInfoMap) + filenames := maps.Keys(fileInfoMap) sort.Strings(filenames) patches := lo.Map(filenames, func(filename string, _ int) string { return p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, - PreviousPath: p.fileInfoMap[filename].previousPath, + PreviousPath: fileInfoMap[filename].previousPath, Plain: plain, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, @@ -245,11 +274,16 @@ func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string { } func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus { - if parent != p.To { + p.mutex.Lock() + to := p.To + fileInfoMap := p.fileInfoMap + p.mutex.Unlock() + + if parent != to { return UNSELECTED } - info, ok := p.fileInfoMap[filename] + info, ok := fileInfoMap[filename] if !ok { return UNSELECTED } @@ -267,16 +301,22 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin // clears the patch func (p *PatchBuilder) Reset() { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = "" p.fileInfoMap = map[string]*fileInfo{} } func (p *PatchBuilder) Active() bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return p.To != "" } func (p *PatchBuilder) IsEmpty() bool { - for _, fileInfo := range p.fileInfoMap { + for _, fileInfo := range p.snapshotFileInfoMap() { if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) { return false } @@ -287,9 +327,12 @@ func (p *PatchBuilder) IsEmpty() bool { // if any of these things change we'll need to reset and start a new patch func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return from != p.From || to != p.To || reverse != p.reverse } func (p *PatchBuilder) AllFilesInPatch() []string { - return lo.Keys(p.fileInfoMap) + return lo.Keys(p.snapshotFileInfoMap()) } From d48c8174d5d1a7af209c2a3935bc61719f610be1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:28 +0200 Subject: [PATCH 68/94] Refresh the patch-building panel on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch-building scope ran RefreshPatchBuildingPanel directly on the refresh worker, where it read the commit-files selection and set the patch view's origin off the UI thread — the latter raced the UI thread's draw. Bounce it onto the UI thread, exactly as the staging panel just above already does, guarded on the generation so a repo switch drops it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 768e7a618..39a8c2fe1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -398,7 +398,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PATCH_BUILDING) { - refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) + 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) { From 1efcfcc1484ee2d4bdb8abc0c04219f1fcfe2577 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 10:50:13 +0200 Subject: [PATCH 69/94] Don't share a live view's buffer when copying its content moveMainContextToTop copies the current top view's content into the view it's promoting, to avoid a flicker. The source can be a main view with a live streaming task (e.g. resolving a conflict promotes the merge-conflicts view over a main view that's mid-diff), and CopyContent both read and published that source's buffer unsafely: - it read the source's lines/viewLines while locking only the destination, racing the task's concurrent Write; and - it aliased the source's row slices into the destination, so the source's ongoing appends (growslice reading the shared array) and refreshViewLinesIfNeeded's in-place wrapping-cache writes (&lines[i]) kept racing this view's rendering after the copy. Lock the source for the read, and shallow-clone the row slices so the destination gets its own arrays. The per-row cell data is immutable once written, so it stays shared -- the clone cost is proportional to the number of rows, not their contents. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 39f2d78a9..b106eb21f 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -7,6 +7,7 @@ package gocui import ( "fmt" "io" + "slices" "strings" "sync" "unicode" @@ -1146,10 +1147,25 @@ func (v *View) CopyContent(from *View) { v.writeMutex.Lock() defer v.writeMutex.Unlock() + // A background task may be streaming output into the source view's buffer + // via Write, so read it under its own lock. The source is always a + // different view than the destination (see the sole caller, + // moveMainContextToTop), and no other code holds two view write locks at + // once, so this can't deadlock. + from.writeMutex.Lock() + defer from.writeMutex.Unlock() + v.clear() - v.lines = from.lines - v.viewLines = from.viewLines + // Clone the row slices rather than sharing them: the source view stays + // live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded + // fills each row's wrapping cache in place via &lines[i]), so sharing the + // backing arrays would race those writes against this view's own rendering. + // This is a shallow clone -- the per-row cell data is immutable once written + // and stays shared, so the cost is proportional to the number of rows, not + // their contents. + v.lines = slices.Clone(from.lines) + v.viewLines = slices.Clone(from.viewLines) v.ox = from.ox v.oy = from.oy v.cx = from.cx From 1b0cc02e1e5106308ec0e1e4ae233987f3d50795 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:21:45 +0200 Subject: [PATCH 70/94] Refresh once when dropping multiple stash entries Dropping a range of stashes ran a refresh after each drop. A refresh issued from the UI thread does its git work on a worker and applies the model update in the background, so firing one per iteration let the workers race: an earlier drop's refresh (which read a stash list that still contained a later-dropped entry) could apply its result last, leaving the stash view showing an entry that git had already removed. Refresh once, after all the drops, so a single worker reads the final stash list. The indices are captured up front and dropped highest-first, so the remaining lower indices stay valid without an intervening refresh. It's also cheaper: one `git stash list` instead of one per entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/stash_controller.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 06e6991c6..a2b7e9e97 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,11 +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}}) for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - err := self.c.Git().Stash.Drop(stashEntries[i].Index) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) - if err != nil { + if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { return err } } From d36ce5155968b318d0ecd3d3b068699410deb332 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 21:40:51 +0200 Subject: [PATCH 71/94] Capture suggestions inputs on the UI thread RefreshSuggestions dispatched to an AsyncHandler worker that read State.FindSuggestions and the prompt's TextArea (via GetPromptInput) from the worker goroutine. The main thread rewrites both in preparePromptPanel when it (re)creates a prompt panel, so an in-flight suggestions worker races those writes -- two data races surfaced under -race (filter_by_path/reword_commit_in_filtering_mode). Capture both on the UI thread (RefreshSuggestions is only ever called from UI-thread handlers) before dispatching to the worker. This is also more correct: we search for the input as it was when dispatched, which is what this request's AsyncHandler id corresponds to. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/suggestions_context.go | 11 +++++++++-- pkg/gui/editors.go | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index fb69b34d9..6f0b3eae6 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -81,10 +81,17 @@ func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) } func (self *SuggestionsContext) RefreshSuggestions() { + // Capture the suggestions function and the prompt input here, on the UI + // thread, rather than inside the worker below: the main thread rewrites both + // (State.FindSuggestions and the prompt's TextArea) when it (re)creates a + // prompt panel, so reading them from the worker races those writes. It's + // also more correct -- we search for the input as it was when dispatched, + // which is what this request's AsyncHandler id corresponds to. + findSuggestionsFn := self.State.FindSuggestions + promptInput := self.c.GetPromptInput() self.State.AsyncHandler.Do(func() func() { - findSuggestionsFn := self.State.FindSuggestions if findSuggestionsFn != nil { - suggestions := findSuggestionsFn(self.c.GetPromptInput()) + suggestions := findSuggestionsFn(promptInput) return func() { self.SetSuggestions(suggestions) } } return func() {} diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 7d3a93de3..37eacf416 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -35,10 +35,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool { v.RenderTextArea() suggestionsContext := gui.State.Contexts.Suggestions - if suggestionsContext.State.FindSuggestions != nil { + // Capture the suggestions function and the input here, on the UI thread; the + // main thread rewrites State.FindSuggestions when it (re)creates a prompt + // panel, so reading it from the worker below would race that write. + if findSuggestions := suggestionsContext.State.FindSuggestions; findSuggestions != nil { input := v.TextArea.GetContent() suggestionsContext.State.AsyncHandler.Do(func() func() { - suggestions := suggestionsContext.State.FindSuggestions(input) + suggestions := findSuggestions(input) return func() { suggestionsContext.SetSuggestions(suggestions) } }) } From 2a7b74d3f310ed33bb4635290358c860e74171af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 22:43:26 +0200 Subject: [PATCH 72/94] Don't access Model in refreshReflogCommits This was old code that was supposed to make a race less likely, but now that we capture model stuff on the UI thread we don't need it any more. --- pkg/gui/controllers/helpers/refresh_helper.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 39a8c2fe1..d0a0610e2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1313,10 +1313,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // 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. @@ -1352,8 +1348,8 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } self.onUIThreadUnlessRepoChanged(env, func() { - model.ReflogCommits = reflogCommits - model.FilteredReflogCommits = filteredReflogCommits + self.c.Model().ReflogCommits = reflogCommits + self.c.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. From 25a3689c016955595a43a90410b235a775af1ae8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:19:03 +0200 Subject: [PATCH 73/94] Refresh the merge conflicts state on the UI thread The "merge conflicts" refresh scope ran on a worker like the others, but unlike them it does UI work rather than git work: RefreshMergeState reads the current context and renders (or escapes) the merge-conflicts view. Reading the context manager and rendering from a worker races the UI thread. Bounce it onto the UI thread with onUIThreadUnlessRepoChanged, exactly as the staging and patch-building scopes already do. Running on the UI thread also lets EscapeMerge push the files context directly instead of deferring the push to a separate UI task; it only needs to drop the merge-conflicts mutex first, because the push renders the newly focused file, which can take the mutex again. The deferred push could lose a race against the same refresh's prompt to continue the rebase/merge: if the prompt opened between RefreshMergeState and the deferred push, the push declined to cover the popup and was dropped, so closing the prompt landed the user in the emptied merge conflicts view. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 --- .../helpers/merge_conflicts_helper.go | 49 ++++++++----------- pkg/gui/controllers/helpers/refresh_helper.go | 10 +++- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 175bc3cc0..34ae285f0 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -51,32 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() { self.context().GetState().Reset() } -func (self *MergeConflictsHelper) EscapeMerge(background bool) error { - self.resetMergeState() +// EscapeMerge returns from the merge conflicts view to the files context. It +// must be called on the UI thread, without the merge-conflicts mutex held: +// pushing the files context renders the newly focused file to the main view, +// which can take the mutex again (via SetMergeState). +func (self *MergeConflictsHelper) EscapeMerge() { + self.ResetMergeState() - // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file - onUIThread := self.c.OnUIThread - if background { - // Reached from a background files refresh; keep it off the busy count - // (see the *Background dispatch methods) so it doesn't block a repo switch. - onUIThread = self.c.OnUIThreadBackground + // The files refresh may already have opened the prompt to continue the + // rebase/merge on top of us (if all conflicts are resolved); in that case + // don't push the files context over it. + if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } - onUIThread(func() error { - // There is a race condition here: refreshing the files scope can trigger the - // confirmation context to be pushed if all conflicts are resolved (prompting - // to continue the merge/rebase. In that case, we don't want to then push the - // files context over it. - // So long as both places call OnUIThread, we're fine. - if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil - }) - return nil } -func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) { - hasConflicts, err := self.setMergeStateWithoutLock(path) +// SetConflictsAndRender re-reads the file being merged and re-renders the +// merge conflicts view. Returns whether the file still has conflicts. +func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { + self.context().GetMutex().Lock() + defer self.context().GetMutex().Unlock() + + hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) if err != nil { return false, err } @@ -126,21 +122,18 @@ func (self *MergeConflictsHelper) Render() { }) } -func (self *MergeConflictsHelper) RefreshMergeState(background bool) error { - self.c.Contexts().MergeConflicts.GetMutex().Lock() - defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() - +func (self *MergeConflictsHelper) RefreshMergeState() error { if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY { return nil } - hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath()) + hasConflicts, err := self.SetConflictsAndRender() if err != nil { return err } if !hasConflicts { - return self.EscapeMerge(background) + self.EscapeMerge() } return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d0a0610e2..4fedf9c7d 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -411,7 +411,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) + 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) From 9f2886f96f75f0c127212562e05cc4d53e49b147 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:37:38 +0200 Subject: [PATCH 74/94] Hold the file-path suggestions trie outside the model The file-path suggestions trie is rebuilt asynchronously and then read by the suggestions search, which runs on an AsyncHandler worker. It lived in Model().FilesTrie, so that worker read the (UI-thread-only) model. Move it to an atomic pointer on the SuggestionsHelper instead: it's the only place that uses it, the helper is recreated per repo (so the cache still resets on a repo switch), and an atomic pointer is safe to store from the build and load from the search worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/suggestions_helper.go | 22 +++++++++++++------ pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 4 ---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index 8a5916816..8784d82fc 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -3,6 +3,7 @@ package helpers import ( "fmt" "strings" + "sync/atomic" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -28,14 +29,20 @@ import ( type SuggestionsHelper struct { c *HelperCommon + + // filesTrie holds the repo's file paths for file-path suggestions. It's + // rebuilt asynchronously and read from the suggestions worker goroutine, so + // it lives here as an atomic pointer rather than in the (UI-thread-only) + // model. + filesTrie atomic.Pointer[patricia.Trie] } func NewSuggestionsHelper( c *HelperCommon, ) *SuggestionsHelper { - return &SuggestionsHelper{ - c: c, - } + self := &SuggestionsHelper{c: c} + self.filesTrie.Store(patricia.NewTrie()) + return self } func (self *SuggestionsHelper) getRemoteNames() []string { @@ -137,9 +144,9 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type trie.Insert(patricia.Prefix(file), file) } + // cache the trie for future use + self.filesTrie.Store(trie) self.c.OnUIThread(func() error { - // cache the trie for future use - self.c.Model().FilesTrie = trie self.c.Contexts().Suggestions.RefreshSuggestions() return nil }) @@ -148,9 +155,10 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type }) return func(input string) []*types.Suggestion { + filesTrie := self.filesTrie.Load() matchingNames := []string{} if self.c.UserConfig().Gui.UseFuzzySearch() { - _ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { + _ = filesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { matchingNames = append(matchingNames, item.(string)) return nil }) @@ -159,7 +167,7 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type matchingNames = utils.FilterStrings(input, matchingNames, true) } else { substrings := strings.Fields(input) - _ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { + _ = filesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { for _, sub := range substrings { if !utils.CaseAwareContains(item.(string), sub) { return nil diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 9b592ffae..533c01fbd 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -49,7 +49,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) const StartupPopupVersion = 5 @@ -639,7 +638,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { FilteredReflogCommits: make([]*models.Commit, 0), ReflogCommits: make([]*models.Commit, 0), BisectInfo: git_commands.NewNullBisectInfo(), - FilesTrie: patricia.NewTrie(), Authors: map[string]*models.Author{}, MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), HashPool: &utils.StringPool{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6e7f72541..6d11e29db 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -11,7 +11,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) type HelperCommon struct { @@ -348,9 +347,6 @@ type Model struct { MainBranches *git_commands.MainBranches - // for displaying suggestions while typing in a file name - FilesTrie *patricia.Trie - Authors map[string]*models.Author HashPool *utils.StringPool From 87ef96974e26ba31b34eae4acfc5d1365a86ddc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:42:24 +0200 Subject: [PATCH 75/94] Check for exec todos on the UI thread hasExecTodos reads Model().Commits. genericMergeCommandImpl evaluates it when deciding whether to use a subprocess, and on the recursive auto-skip path that runs on a worker -- so the read raced the UI thread. Bounce it onto the UI thread there, keyed off the calledFromWorker flag the function already carries (on the UI-thread entry path the read stays inline). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index a8696a21c..7c7ab3e9a 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -127,7 +127,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || // but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build // tasks whose output the user will want to see in the terminal - (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos()) + (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker)) if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction @@ -168,16 +168,31 @@ func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehav return types.KeepCommitSelectionByHash } -func (self *MergeAndRebaseHelper) hasExecTodos() bool { - for _, commit := range self.c.Model().Commits { - if !commit.IsTODO() { - break - } - if commit.Action == todo.Exec { - return true +func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool { + check := func() bool { + for _, commit := range self.c.Model().Commits { + if !commit.IsTODO() { + break + } + if commit.Action == todo.Exec { + return true + } } + return false } - return false + + // This reads the model, which is only safe on the UI thread, so bounce there + // when we're being called from a worker. + if !calledFromWorker { + return check() + } + + result := false + _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + result = check() + return nil + }) + return result } var conflictStrings = []string{ From c23bcd6d9423f3fd51bd8b8d39a5b01dbc52ca65 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 15:19:55 +0200 Subject: [PATCH 76/94] Store the UI thread ID earlier --- pkg/gocui/gui.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index dbef57b59..20beb2af7 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -293,6 +293,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.playRecording = opts.PlayRecording + // Record the UI thread here, at construction. This assumes NewGui is called + // on the same goroutine that will run MainLoop, which holds for all our + // callers -- and it means IsUIThread is already correct for the UI work that + // runs during startup, before we reach MainLoop. + g.uiThreadID.Store(goid.Get()) + return g, nil } @@ -977,8 +983,6 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { func (g *Gui) MainLoop() error { defer close(g.loopExited) - g.uiThreadID.Store(goid.Get()) - go func() { for { select { From e299de32700ab1a2d865652b6375a6c1131ec76c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:11:07 +0200 Subject: [PATCH 77/94] Assert that Model() and Context() are only accessed on the UI thread The bounce model requires that a worker never touch UI-thread-owned state: it should capture what it needs on the UI thread and pass that in. Guard the two central accessors -- Model() (the git model) and Context() (the context manager, which owns the mutable current-context/stack) -- with a debug-only panic when they're called off the UI thread. Since the integration tests run with -debug, a stray worker access now fails deterministically and points at itself, rather than surfacing later as a probabilistic data race. One supporting change make the assertion usable: the integration test driver inspects gui state from the test goroutine, so GuiDriver.CurrentContext reads the context manager directly rather than through the now-guarded c.Context(). Contexts() (the registry of context objects) is deliberately left unguarded: workers legitimately fetch a context to grab its mutex or check identity, so a blanket assertion there would flag safe accesses. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui_common.go | 12 ++++++++++++ pkg/gui/gui_driver.go | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 69ec44781..80b2b9ded 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -58,7 +58,18 @@ func (self *guiCommon) PauseBackgroundRefreshes(pause bool) { self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause) } +// assertOnUIThread panics (in debug builds) if called from a worker goroutine. +// Use it to guard accessors for state that only the UI thread may touch, so +// that a stray worker access fails deterministically -- and points at itself -- +// rather than surfacing later as a probabilistic data race. +func (self *guiCommon) assertOnUIThread(accessor string) { + if self.GetConfig().GetDebug() && !self.GocuiGui().IsUIThread() { + panic(accessor + " accessed from a worker") + } +} + func (self *guiCommon) Context() types.IContextMgr { + self.assertOnUIThread("Context()") return self.gui.State.ContextMgr } @@ -113,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes { } func (self *guiCommon) Model() *types.Model { + self.assertOnUIThread("Model()") return self.gui.State.Model } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 31094b253..7bd31d93d 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -92,7 +92,10 @@ func (self *GuiDriver) Keys() config.KeybindingConfig { } func (self *GuiDriver) CurrentContext() types.Context { - return self.gui.c.Context().Current() + // Read the context manager directly rather than through c.Context(): the + // driver runs on the test goroutine, not the UI thread, so it must bypass + // the UI-thread assertion that accessor carries. + return self.gui.State.ContextMgr.Current() } func (self *GuiDriver) ContextForView(viewName string) types.Context { From 5769ab219069daafbbdba09c2e8529cd17d250f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:16:25 +0200 Subject: [PATCH 78/94] Don't retry failed integration tests Now that we solved all known concurrency issues and our tests should be 100% deterministic, reduce MaxAttempts to 1 so that tests fail immediately. We don't want to paper over existing flakiness any more. --- pkg/integration/clients/go_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 211e73d28..11f6e754e 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -56,7 +56,7 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 2, + MaxAttempts: 1, }) assert.NoError(t, err) From 5055c4fb654b1780330f941c27e828fbf44ebcd8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:37:23 +0200 Subject: [PATCH 79/94] Fetch GitHub pull requests as a background task Every full refresh includes the PULL_REQUESTS scope, and the worker it spawns inherited the refresh's foreground/background flag. Full foreground refreshes happen at startup, after switching repos or worktrees, and when the terminal regains focus, so the GitHub API request ran as a foreground task there, keeping Busy() true until it completed. On a healthy network that's a few hundred milliseconds and nobody notices; on a very slow one the request can stall for minutes, and every attempt to switch repos in that window was refused with "Can't switch repositories while an operation is in progress" even though lazygit looked completely idle. (The request has no visible status; at most, a background fetch hanging on the same bad network was showing its "Fetching..." spinner, pointing the blame at the wrong operation.) The switch-safety guard only needs to wait for operations whose remaining git commands would run against the wrong repo after a switch. The pull-request fetch runs no git commands at all, and its model writes are dropped when the repo generation has changed in the meantime, so there is no reason for it to block switching. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4fedf9c7d..a7c18aeb8 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -365,7 +365,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PULL_REQUESTS) { - self.onWorker(env.background, func(gocui.Task) error { + // 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() @@ -373,7 +383,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // 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) + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, prEnv) self.c.Log.Infof("refreshed pull requests in %s", time.Since(t)) return nil }) From 7360a8459d21be50acb135d8cc43c5e2be4f7b90 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:51:38 +0200 Subject: [PATCH 80/94] Give the GitHub GraphQL requests a timeout The http.Client used for fetching pull requests had no timeout, so on a network that silently drops packets a request could stay in flight until the OS-level TCP timeouts kick in, which can take many minutes. The fetch has no visible status, so nothing tells the user it is still running; bounding it keeps the refresh's worst case short, and the next refresh simply tries again. Co-Authored-By: Claude Fable 5 --- pkg/commands/git_commands/github.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index e05472ef1..b74815301 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -210,7 +210,10 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, req.Header.Set("Authorization", "token "+token) req.Header.Set("Content-Type", "application/json") - client := &http.Client{} + // Bound the request so that a dead or extremely slow network can't leave + // the pull-request refresh in flight for minutes. The data is auxiliary, + // so giving up and retrying on the next refresh beats waiting. + client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err From a1561a5e6967cf8a7f93c2f787afed4c773a9d61 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 15:55:01 +0200 Subject: [PATCH 81/94] Render the app status in a single background render loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each status used to start a spinner render loop of its own, running on a worker that inherited the foreground/background flavor of the status's owner, and exiting only once the entire status stack was empty. That shape had a real bug: a foreground operation's loop could be kept alive by someone else's status. Finish a quick operation with a waiting status while a background fetch's "Fetching..." status is still showing, and the operation's render loop — a foreground worker task — keeps ticking until the fetch ends. Busy() stays true for that whole time, so repo switching is refused even though nothing is in flight anymore; with a fetch hanging on a slow network, that means minutes. The shape was also wasteful: overlapping statuses were each drawn by their own loop (plus a duplicate whenever a task was paused and resumed while another status was showing), all redundantly redrawing the same top status. Replace the per-status loops with a single loop owned by the status stack as a whole: whoever shows the first status starts it, and it exits after drawing a final empty frame once the last status is removed. The claim/release methods on StatusManager keep the loop flag's transitions atomic with the stack under the one mutex, so a status added while the loop is about to exit starts a fresh loop instead of going unrendered. The loop always runs as a background task now: rendering issues no git commands, so it never needs to block repo switching, and a foreground operation's busy-ness is already carried by its own worker task. This is what fixes the bug above, and it retires the need to thread a foreground/background flag through the waiting-status helpers altogether. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 2 +- .../controllers/helpers/app_status_helper.go | 57 ++++++++++--------- pkg/gui/status/status_manager.go | 38 +++++++++++++ 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index f9eff420b..1e2db853f 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -119,7 +119,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { return self.backgroundFetch() - }, nil, true) + }, nil) } return self.backgroundFetch() diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 69daa7d7a..d0bb03395 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,12 +34,7 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - // Render the toast in the background: it's a transient notification, not - // lazygit driving an operation, so it must not count towards being busy — - // otherwise a toast (e.g. the "can't switch, operation in progress" one) - // would itself block a repo switch until it faded. A real operation showing - // a toast still keeps its own foreground task busy independently. - self.renderAppStatus(true) + self.renderAppStatus() } // A custom task for WithWaitingStatus calls; it wraps the original one and @@ -66,14 +61,15 @@ func (self appStatusHelperTask) Continue() { // WithWaitingStatus wraps a function and shows a waiting status while the function is still executing func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) { self.c.OnWorker(func(task gocui.Task) error { - return self.WithWaitingStatusImpl(message, f, task, false) + return self.WithWaitingStatusImpl(message, f, task) }) } -// background reports whether this waiting status belongs to a background routine -// (the auto-fetch poller); when it does, the spinner it drives must not count -// towards lazygit being busy, or it'd block repo switches while a fetch runs. -func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task, background bool) error { +// WithWaitingStatusImpl is WithWaitingStatus for callers that already run on a +// goroutine of their own (e.g. the auto-fetch poller) rather than wanting the +// work dispatched to a worker. task is used to hide the status while the task +// is paused; it may be nil for callers whose f ignores its task. +func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { // A waiting status means lazygit is driving a git operation itself (often // one that internally runs a rebase and continues it). Pause the background // routines for its duration so they don't refresh from an intermediate @@ -81,7 +77,7 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) - return self.statusMgr().WithWaitingStatus(message, func() { self.renderAppStatus(background) }, func(waitingStatusHandle *status.WaitingStatusHandle) error { + return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } @@ -112,7 +108,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu self.modeHelper.SetSuppressRebasingMode(false) return self.c.GocuiGui().EndBlockingEvents() }) - return self.WithWaitingStatusImpl(message, f, task, false) + return self.WithWaitingStatusImpl(message, f, task) }) } @@ -125,33 +121,36 @@ func (self *AppStatusHelper) GetStatusString() string { return appStatus } -func (self *AppStatusHelper) renderAppStatus(background bool) { - // A background waiting status (auto-fetch) must not count towards lazygit - // being busy, so its spinner worker and per-frame UI updates go through the - // background variants. - onWorker := self.c.OnWorker - onUIThread := self.c.OnUIThread - onUIThreadContentOnly := self.c.OnUIThreadContentOnly - if background { - onWorker = self.c.OnWorkerBackground - onUIThread = self.c.OnUIThreadBackground - onUIThreadContentOnly = self.c.OnUIThreadContentOnlyBackground +// renderAppStatus ensures the render loop that keeps the app-status view up to +// date is running. There is one loop for the whole status stack, no matter how +// many statuses are showing: it draws whatever the top status currently is, +// and exits after drawing a final empty frame once the last status is removed. +// +// The loop always runs as a background task, regardless of what kind of +// operation owns a status: rendering runs no git commands, so it must never +// count towards lazygit being busy — otherwise it would block repo switching +// for as long as anything is showing (e.g. for the whole duration of a hung +// background fetch, or of a toast fading). A foreground operation's busy-ness +// is carried by its own worker task, not by the renderer. +func (self *AppStatusHelper) renderAppStatus() { + if !self.statusMgr().ClaimRenderLoop() { + return } - onWorker(func(_ gocui.Task) error { + self.c.OnWorkerBackground(func(_ gocui.Task) error { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() prevAppStatus := "" for range ticker.C { appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - update := onUIThreadContentOnly + update := self.c.OnUIThreadContentOnlyBackground if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) { // Need a full layout whenever the width of the status string changes. This can't // happen during normal spinning because we validate that all spinner frames have // the same width, so typically this will only be triggered at the beginning and end // of a status, or if the status string changes midway for some reason. - update = onUIThread + update = self.c.OnUIThreadBackground } update(func() error { self.c.Views().AppStatus.FgColor = color @@ -160,7 +159,9 @@ func (self *AppStatusHelper) renderAppStatus(background bool) { }) prevAppStatus = appStatus - if appStatus == "" { + // Checked after rendering, so that the frame which clears the view + // has already been drawn when we exit. + if self.statusMgr().ReleaseRenderLoopIfEmpty() { break } } diff --git a/pkg/gui/status/status_manager.go b/pkg/gui/status/status_manager.go index 414568a69..35e1b7746 100644 --- a/pkg/gui/status/status_manager.go +++ b/pkg/gui/status/status_manager.go @@ -17,6 +17,11 @@ type StatusManager struct { statuses []appStatus nextId int mutex deadlock.Mutex + + // Whether a render loop is currently drawing the statuses. Guarded by + // mutex, so that claiming and releasing the loop stay atomic with the + // changes to statuses; see ClaimRenderLoop and ReleaseRenderLoopIfEmpty. + renderLoopRunning bool } // Can be used to manipulate a waiting status while it is running (e.g. pause @@ -90,6 +95,39 @@ func (self *StatusManager) HasStatus() bool { return len(self.statuses) > 0 } +// ClaimRenderLoop is called by whoever just added a status; it reports whether +// they must start the render loop. When it returns false, a loop is already +// running and will pick the new status up on its next tick. +func (self *StatusManager) ClaimRenderLoop() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + if self.renderLoopRunning { + return false + } + + self.renderLoopRunning = true + return true +} + +// ReleaseRenderLoopIfEmpty is called by the render loop after each frame it +// draws; a true result releases the loop's claim and tells it to exit, because +// there are no statuses left to draw. The emptiness check and the release are +// atomic with respect to ClaimRenderLoop, so a status added around this moment +// either sees the still-running loop or starts a fresh one — it can't end up +// unrendered. +func (self *StatusManager) ReleaseRenderLoopIfEmpty() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + if len(self.statuses) > 0 { + return false + } + + self.renderLoopRunning = false + return true +} + func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int { self.mutex.Lock() defer self.mutex.Unlock() From 33a2dc302efe6db7020b4bc838f8a204f7d782c4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 19:19:47 +0200 Subject: [PATCH 82/94] Suppress command logs for tag commands The command log is supposed to show only commands initiated by the user; these are commands that we run to get information for rendering, so they pollute the log and are confusing. --- pkg/commands/git_commands/tag.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go index 4d15027e1..c6fe2e807 100644 --- a/pkg/commands/git_commands/tag.go +++ b/pkg/commands/git_commands/tag.go @@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).Run() == nil + return self.cmd.New(cmdArgs).DontLog().Run() == nil } func (self *TagCommands) LocalDelete(tagName string) error { @@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).RunWithOutput() + return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { @@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { Arg("refs/tags/" + tagName). ToArgv() - output, err := self.cmd.New(cmdArgs).RunWithOutput() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return strings.TrimSpace(output) == "tag", err } From 197916aafb3b9a7f6e7535263d44bf78b0b7638d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 19:22:48 +0200 Subject: [PATCH 83/94] Suppress command logs for git calls related to the ctrl+f command --- pkg/commands/git_commands/blame.go | 2 +- pkg/gui/controllers/helpers/fixup_helper.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/blame.go b/pkg/commands/git_commands/blame.go index aba1c63fe..e1a9469eb 100644 --- a/pkg/commands/git_commands/blame.go +++ b/pkg/commands/git_commands/blame.go @@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL Arg("--"). Arg(filename) - return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput() + return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput() } diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index a958e58a8..e8fa43f2d 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -199,12 +199,12 @@ func (self *FixupHelper) getDiff() (string, bool, error) { // Try staged changes first hasStagedChanges := true - diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput() + diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).DontLog().RunWithOutput() if err == nil && diff == "" { hasStagedChanges = false // If there are no staged changes, try unstaged changes - diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput() + diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).DontLog().RunWithOutput() } return diff, hasStagedChanges, err From b37141156719332fa53f268bce91f33ad34d9ac3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 22:52:47 +0200 Subject: [PATCH 84/94] Recommend the gopls MCP tools for symbol navigation in AGENTS.md Grep-based navigation needs manual filtering for the many colliding method names in this codebase, while gopls answers reference and implementation questions type-aware and exactly. Scope the guidance to the symbol tools and keep grep for textual searches: gopls' own MCP instructions prescribe running vulncheck at session start and go_file_context after every file read, which costs more than it helps here. The server is registered per user and machine, so sessions without it must just fall back to grep rather than try to set it up. --- AGENTS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 88e818249..2cafd9d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,24 @@ Windows box has only `just`). (most useful with `--sandbox` or `--slow`). - `just lint` — run golangci-lint. +## Prefer gopls MCP tools for Go symbol questions + +When the gopls MCP tools are available in the session, prefer them over grep +for type-aware questions about Go code: who calls a function or method +(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or +inspecting a package's API (`go_package_api`). Method names in this codebase +collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep +needs manual filtering that gopls doesn't. This includes code under +`vendor/`, which gopls resolves as part of the module build. + +Grep remains the right tool for strings, comments, config keys, non-Go +files, and anything textual. Don't adopt the full workflow from +`gopls mcp -instructions` (vulncheck on session start, `go_file_context` +after every file read); that overhead isn't worth it here. + +If the tools aren't available in a session, fall back to grep silently — +don't try to install, register, or start the server. + ## When to commit Do not leave completed work uncommitted. Once a logical unit of work is done From 3ed6ce8f670caf023bf510f6c7b3592e472d3b27 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:55:34 +0200 Subject: [PATCH 85/94] Add test showing that resuming after a suspend schedules no redraw When lazygit is suspended with ctrl+z and brought back with fg, nothing deliberately triggers a redraw. The screen only repaints because the UI thread happens to have a flush pending from the suspend keybinding, and that flush races the SIGCONT handler's resume; when it loses in the right way, the terminal shows a blank screen until the next input event arrives (#5309). --- pkg/gocui/suspend_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/gocui/suspend_test.go diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go new file mode 100644 index 000000000..25a15c921 --- /dev/null +++ b/pkg/gocui/suspend_test.go @@ -0,0 +1,27 @@ +package gocui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResumeSchedulesRedraw(t *testing.T) { + g := newTestGui(t) + + assert.NoError(t, g.Suspend()) + assert.NoError(t, g.Resume()) + + ev := GocuiEvent{Type: eventNone} + select { + case ev = <-g.gEvents: + case <-time.After(100 * time.Millisecond): + } + + /* EXPECTED: + assert.Equal(t, eventResize, ev.Type, + "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") + ACTUAL: */ + assert.Equal(t, eventNone, ev.Type) +} From d887a41ad2c386ca8b43fab9262553525e57af72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:58:32 +0200 Subject: [PATCH 86/94] Don't flush the screen while suspended When suspending with ctrl+z, the suspend keybinding handler disengages the screen and then sends SIGSTOP to the process group, so the UI thread freezes at the return from kill(2) with the handler's follow-up flush still pending. When fg continues the process, that pending flush races the SIGCONT handler's Resume. If the flush wins, Show() draws against the disengaged screen, whose cell buffer tcell has released to 0x0 while its width/height still hold the old size; drawCell() then reports width 0 for the out-of-range cell, the draw loop's 'x += width - 1' never advances, and the UI thread spins forever while holding the tcell screen lock. Resume in turn blocks forever on that lock, so the screen never re-engages and no input is ever read again: the hard stall of #5309, only recoverable by killing the process. Guard both flush paths with the suspended flag. For the flag to guarantee that the screen is engaged whenever it is false, Resume must clear it only after re-engaging (it used to clear it before); Suspend already sets it before disengaging. This also covers the pre-existing unsynchronized suspended check in draw(), which is subsumed by the guards and can go. The regression test cannot use the demonstrate-then-fix pattern: on unfixed code the flush goroutine spins holding the screen lock, which deadlocks any subsequent screen call including the test cleanup's Close(). --- pkg/gocui/gui.go | 42 ++++++++++++++++++++++++++++++------ pkg/gocui/suspend_test.go | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 20beb2af7..2785f07d4 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1470,6 +1470,11 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { // flush updates the gui, re-drawing frames and buffers. func (g *Gui) flush() error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + // pretty sure we don't need this, but keeping it here in case we get weird visual artifacts // g.clear(g.FgColor, g.BgColor) @@ -1502,6 +1507,11 @@ func (g *Gui) flush() error { // actually-changed cells are emitted to the terminal. // Will also redraw any views that overlap tainted views func (g *Gui) flushContentOnly(views []*View) error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + for _, v := range viewsToRedrawContentOnly(views) { if err := g.draw(v); err != nil { return err @@ -1555,10 +1565,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error { // draw manages the cursor and calls the draw function of a view. func (g *Gui) draw(v *View) error { - if g.suspended { - return nil - } - if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { return nil } @@ -1930,6 +1936,14 @@ func (g *Gui) onFocus(ev *GocuiEvent) error { return nil } +// While g.suspended is true, nothing must be drawn to the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// which then blocks Resume (and with it all further input) forever. For the +// flag to guarantee that, it must only ever be false while the screen is +// engaged: Suspend sets it before disengaging, and Resume clears it only +// after re-engaging. + func (g *Gui) Suspend() error { g.suspendedMutex.Lock() defer g.suspendedMutex.Unlock() @@ -1940,7 +1954,12 @@ func (g *Gui) Suspend() error { g.suspended = true - return g.screen.Suspend() + if err := g.screen.Suspend(); err != nil { + g.suspended = false + return err + } + + return nil } func (g *Gui) Resume() error { @@ -1951,9 +1970,20 @@ func (g *Gui) Resume() error { return errors.New("Cannot resume because we are not suspended") } + if err := g.screen.Resume(); err != nil { + return err + } + g.suspended = false - return g.screen.Resume() + return nil +} + +func (g *Gui) isSuspended() bool { + g.suspendedMutex.Lock() + defer g.suspendedMutex.Unlock() + + return g.suspended } // matchView returns if the keybinding matches the current view (and the view's context) diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index 25a15c921..d5ea5a132 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -7,6 +7,51 @@ import ( "github.com/stretchr/testify/assert" ) +// A flush while suspended must return without touching the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// blocking the resume triggered by fg (#5309). The flush runs in a goroutine +// so that a regression fails the test instead of hanging the suite. +func TestFlushIsNoOpWhileSuspended(t *testing.T) { + tests := []struct { + name string + flush func(g *Gui) error + }{ + {"flush", func(g *Gui) error { return g.flush() }}, + {"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Deliberately not newTestGui: its cleanup closes the screen, + // which would deadlock on the screen lock if a regression makes + // the flush below spin. + g, err := NewGui(NewGuiOpts{ + OutputMode: OutputNormal, + Headless: true, + Width: 80, + Height: 24, + }) + assert.NoError(t, err) + + assert.NoError(t, g.Suspend()) + + flushReturned := make(chan error, 1) + go func() { flushReturned <- tc.flush(g) }() + + select { + case err := <-flushReturned: + assert.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("flush touched the suspended screen and got stuck") + } + + assert.NoError(t, g.Resume()) + g.Close() + }) + } +} + func TestResumeSchedulesRedraw(t *testing.T) { g := newTestGui(t) From 319f43e1660cfee71f8479626c8f48020f9642cc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 14:00:06 +0200 Subject: [PATCH 87/94] Schedule a redraw when resuming from suspension Until now the repaint after fg was accidental: it only happened because the suspend keybinding handler still had a flush pending on the UI thread, and only if that flush happened to run after the SIGCONT handler had re-engaged the screen. Now that flushes are skipped while suspended, losing that race would leave the screen blank until the next input event arrives, so schedule a redraw explicitly (#5309). --- pkg/gocui/gui.go | 5 +++++ pkg/gocui/suspend_test.go | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 2785f07d4..57818960c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1976,6 +1976,11 @@ func (g *Gui) Resume() error { g.suspended = false + // Schedule a redraw of the whole screen. Nothing else guarantees one: + // flushes are skipped while suspended, and after re-engaging the screen + // the terminal shows nothing until we draw again. + go func() { g.gEvents <- GocuiEvent{Type: eventResize} }() + return nil } diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index d5ea5a132..ded220bea 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -64,9 +64,6 @@ func TestResumeSchedulesRedraw(t *testing.T) { case <-time.After(100 * time.Millisecond): } - /* EXPECTED: assert.Equal(t, eventResize, ev.Type, "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") - ACTUAL: */ - assert.Equal(t, eventNone, ev.Type) } From 94e5f570f668a6332439970a46e2d52909178549 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 20:57:35 +0200 Subject: [PATCH 88/94] Bump tcell to v3.4.1 to fix drawing on a suspended screen The previous two commits stop gocui from flushing while suspended, but that guard cannot be fully airtight from gocui's side: it is a check-then-act on the suspended flag, so a flush racing the suspend itself (e.g. from a spinner goroutine) could still reach the screen just as it disengages, and tcell's disengageFinish mutates the cell buffer without holding the screen lock. Upstream now closes this at the source (gdamore/tcell#1139): draw() returns immediately on a disengaged screen, and the draw scan loop can no longer stall on the width-0 cells that a released cell buffer reports (#5309). The delta over the previously pinned snapshot is these two fixes, a CSI R input decode fix, a wasm packaging chore, and dependency bumps. Co-Authored-By: Claude Fable 5 --- go.mod | 15 ++--- go.sum | 32 ++++----- .../github.com/gdamore/tcell/v3/CHANGESv3.md | 10 ++- .../gdamore/tcell/v3/README-wasm.md | 2 + vendor/github.com/gdamore/tcell/v3/input.go | 1 + vendor/github.com/gdamore/tcell/v3/tscreen.go | 11 ++++ vendor/golang.org/x/mod/modfile/read.go | 8 +-- vendor/golang.org/x/mod/modfile/rule.go | 65 ++++++++++++++++--- .../golang.org/x/sync/semaphore/semaphore.go | 17 +++-- vendor/golang.org/x/sys/unix/syscall_linux.go | 1 + .../x/sys/unix/syscall_linux_386.go | 1 - .../x/sys/unix/syscall_linux_amd64.go | 1 - .../x/sys/unix/syscall_linux_arm.go | 1 - .../x/sys/unix/syscall_linux_arm64.go | 1 - .../x/sys/unix/syscall_linux_loong64.go | 1 - .../x/sys/unix/syscall_linux_mips64x.go | 1 - .../x/sys/unix/syscall_linux_mipsx.go | 1 - .../x/sys/unix/syscall_linux_ppc.go | 1 - .../x/sys/unix/syscall_linux_ppc64x.go | 1 - .../x/sys/unix/syscall_linux_riscv64.go | 1 - .../x/sys/unix/syscall_linux_s390x.go | 1 - .../x/sys/unix/syscall_linux_sparc64.go | 1 - vendor/golang.org/x/sys/unix/zerrors_linux.go | 8 ++- .../golang.org/x/sys/unix/zsyscall_linux.go | 17 +++++ .../x/sys/unix/zsyscall_linux_386.go | 17 ----- .../x/sys/unix/zsyscall_linux_amd64.go | 17 ----- .../x/sys/unix/zsyscall_linux_arm.go | 17 ----- .../x/sys/unix/zsyscall_linux_arm64.go | 17 ----- .../x/sys/unix/zsyscall_linux_loong64.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips64.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips64le.go | 17 ----- .../x/sys/unix/zsyscall_linux_mipsle.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc64.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc64le.go | 17 ----- .../x/sys/unix/zsyscall_linux_riscv64.go | 17 ----- .../x/sys/unix/zsyscall_linux_s390x.go | 17 ----- .../x/sys/unix/zsyscall_linux_sparc64.go | 17 ----- .../x/sys/windows/security_windows.go | 36 ++++++++++ .../x/sys/windows/syscall_windows.go | 8 ++- .../golang.org/x/sys/windows/types_windows.go | 1 + vendor/golang.org/x/text/cases/context.go | 2 +- vendor/golang.org/x/text/cases/map.go | 4 +- .../x/text/unicode/norm/forminfo.go | 9 ++- vendor/golang.org/x/text/unicode/norm/iter.go | 8 +-- .../x/text/unicode/norm/normalize.go | 20 +++--- vendor/modules.txt | 16 ++--- 48 files changed, 216 insertions(+), 342 deletions(-) diff --git a/go.mod b/go.mod index 12af7493b..515bb8f1b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 + github.com/gdamore/tcell/v3 v3.4.1 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -38,8 +38,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -65,11 +65,10 @@ require ( github.com/onsi/gomega v1.34.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index 2f734de11..1b8cb7d66 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw= +github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w= +github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -139,19 +139,19 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md index 26a0c2e57..c3fcb3f4f 100644 --- a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md +++ b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md @@ -100,7 +100,7 @@ These functions weren't reliable and served no useful purpose. `NewConsoleScreen` is removed as is support for Windows console mode. Instead this uses the more modern Windows VT modes. -As a consequence, this means that _Tcell_ on Windows requires at least Winows 10 build 1703 (the Creators Update). +As a consequence, this means that _Tcell_ on Windows requires at least Windows 10 build 1703 (the Creators Update). If you are using a version of Windows 10 older than that, you should really upgrade for _many_ reasons, not just because _Tcell_ doesn't support it anymore. @@ -108,3 +108,11 @@ because _Tcell_ doesn't support it anymore. This structure, and the associated `NewInputProcessor` function, were made public incorrectly. They are not part of our public API going forward, and are now private symbols. + +## SimulationScreen is Removed + +While never part of the public _Tcell_ API, some projects may have used the +`SimulationScreen` for their own tests. That facility was very limited, and +we implemented a much more complete emulation of a terminal in `MockScreen` +and `MockTerm`. (To be clear, those facilities are still intended for _Tcell_'s +own testing, and are still not part of the public API.) diff --git a/vendor/github.com/gdamore/tcell/v3/README-wasm.md b/vendor/github.com/gdamore/tcell/v3/README-wasm.md index 4e29a3dac..1a92a4e5c 100644 --- a/vendor/github.com/gdamore/tcell/v3/README-wasm.md +++ b/vendor/github.com/gdamore/tcell/v3/README-wasm.md @@ -24,6 +24,8 @@ cp -R webfiles/ghostty-web /path/to/dir/to/serve/ The vendored `ghostty-web.js` is intentionally browser-only. Its upstream Node `readFile` fallback import is removed so browser-oriented servers and bundlers such as Vite do not try to resolve a Node file-system shim; the bundled code loads `ghostty-vt.wasm` with `fetch`. +The vendored `ghostty-web.js` is also de-inlined: upstream embeds a base64 copy of `ghostty-vt.wasm` twice inside the JS (as default candidates for `Ghostty.load()`), which more than tripled the shipped bytes. Those inline `data:application/wasm;base64,...` defaults are removed; `tcell.js` passes an explicit URL to `Ghostty.load()`, and the `./ghostty-vt.wasm` / `/ghostty-vt.wasm` relative paths remain as no-argument fallbacks. The wasm is therefore shipped once, as the separate `ghostty-vt.wasm`. + For example: ```sh diff --git a/vendor/github.com/gdamore/tcell/v3/input.go b/vendor/github.com/gdamore/tcell/v3/input.go index 58c8a7e0d..74e234d1e 100644 --- a/vendor/github.com/gdamore/tcell/v3/input.go +++ b/vendor/github.com/gdamore/tcell/v3/input.go @@ -222,6 +222,7 @@ var csiAllKeys = map[csiParamMode]keyMap{ {M: 'L'}: {Key: KeyInsert}, {M: 'P'}: {Key: KeyF1}, // except for aixterm, where this is Delete {M: 'Q'}: {Key: KeyF2}, + {M: 'R'}: {Key: KeyF3}, {M: 'S'}: {Key: KeyF4}, {M: 'Z'}: {Key: KeyBacktab}, {M: 'a'}: {Key: KeyUp, Mod: ModShift}, diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 9fe3cf8fe..d50101903 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -1009,6 +1009,13 @@ func (t *tScreen) hideCursor() { } func (t *tScreen) draw() { + if !t.running { + // While disengaged (e.g. suspended) the terminal belongs to some + // other application, so we must not emit anything; also the cell + // buffer is released, so there is nothing valid to draw from. + return + } + // clobber cursor position, because we're going to change it all t.cx = -1 t.cy = -1 @@ -1040,6 +1047,10 @@ func (t *tScreen) draw() { // actually will *draw* it. t.cells.SetDirty(x+1, y, true) } + } else if width < 1 { + // drawCell reports width 0 for coordinates outside the + // cell buffer; never let the scan stall + width = 1 } x += width - 1 } diff --git a/vendor/golang.org/x/mod/modfile/read.go b/vendor/golang.org/x/mod/modfile/read.go index 504a2f1df..5b528c718 100644 --- a/vendor/golang.org/x/mod/modfile/read.go +++ b/vendor/golang.org/x/mod/modfile/read.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "os" + "slices" "strconv" "strings" "unicode" @@ -105,8 +106,7 @@ func (x *FileSyntax) addLine(hint Expr, tokens ...string) *Line { if hint == nil { // If no hint given, add to the last statement of the given type. Loop: - for i := len(x.Stmt) - 1; i >= 0; i-- { - stmt := x.Stmt[i] + for _, stmt := range slices.Backward(x.Stmt) { switch stmt := stmt.(type) { case *Line: if stmt.Token != nil && stmt.Token[0] == tokens[0] { @@ -718,9 +718,7 @@ func (in *input) assignComments() { } // Assign suffix comments to syntax immediately before. - for i := len(in.post) - 1; i >= 0; i-- { - x := in.post[i] - + for _, x := range slices.Backward(in.post) { start, end := x.Span() if debug { fmt.Fprintf(os.Stderr, "post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte) diff --git a/vendor/golang.org/x/mod/modfile/rule.go b/vendor/golang.org/x/mod/modfile/rule.go index c5b8305de..9ab203b56 100644 --- a/vendor/golang.org/x/mod/modfile/rule.go +++ b/vendor/golang.org/x/mod/modfile/rule.go @@ -327,6 +327,7 @@ func parseToFile(file string, data []byte, fix VersionFixer, strict bool) (parse } var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?([a-z]+[0-9]+)?$`) + var laxGoVersionRE = lazyregexp.New(`^v?(([1-9][0-9]*)\.(0|[1-9][0-9]*))([^0-9].*)$`) // Toolchains must be named beginning with `go1`, @@ -1272,6 +1273,17 @@ func (f *File) SetRequire(req []*Require) { // SetRequireSeparateIndirect will split it into a direct-only and indirect-only // block. This aids in the transition to separate blocks. func (f *File) SetRequireSeparateIndirect(req []*Require) { + f.setRequireSeparateIndirect(req, false) +} + +// SetRequireAtMostTwo is like SetRequireSeparateIndirect but it aggressively +// consolidates all requirements into at most two blocks (one direct, one indirect). +// It ignores existing blocks and comments when deciding where to place requirements. +func (f *File) SetRequireAtMostTwo(req []*Require) { + f.setRequireSeparateIndirect(req, true) +} + +func (f *File) setRequireSeparateIndirect(req []*Require, simplify bool) { // hasComments returns whether a line or block has comments // other than "indirect". hasComments := func(c Comments) bool { @@ -1304,6 +1316,17 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { } // Examine existing require lines and blocks. + need := make(map[string]*Require) + for _, r := range req { + need[r.Mod.Path] = r + } + lineIndirect := make(map[*Line]bool) + for _, r := range f.Require { + if n := need[r.Mod.Path]; n != nil { + lineIndirect[r.Syntax] = n.Indirect + } + } + var ( // We may insert new requirements into the last uncommented // direct-only and indirect-only blocks. We may also move requirements @@ -1321,7 +1344,9 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // Track the block each requirement belongs to (if any) so we can // move them later. - lineToBlock = make(map[*Line]*LineBlock) + lineToBlock = make(map[*Line]*LineBlock) + directBlockComments []Comment + indirectBlockComments []Comment ) for i, stmt := range f.Syntax.Stmt { switch stmt := stmt.(type) { @@ -1364,6 +1389,24 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { if allIndirect { lastIndirectIndex = i } + if simplify { + anyDirect := false + for _, line := range stmt.Line { + if ind, ok := lineIndirect[line]; ok && !ind { + anyDirect = true + break + } + } + target := &directBlockComments + if !anyDirect && len(stmt.Line) > 0 { + target = &indirectBlockComments + } + if len(*target) > 0 && len(stmt.Comments.Before) > 0 { + *target = append(*target, Comment{Token: "//"}) + } + *target = append(*target, stmt.Comments.Before...) + stmt.Comments.Before = nil + } } } @@ -1422,6 +1465,15 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { lastIndirectBlock = ensureBlock(lastIndirectIndex) } + if simplify { + if len(directBlockComments) > 0 { + lastDirectBlock.Comments.Before = append(lastDirectBlock.Comments.Before, directBlockComments...) + } + if len(indirectBlockComments) > 0 { + lastIndirectBlock.Comments.Before = append(lastIndirectBlock.Comments.Before, indirectBlockComments...) + } + } + // Delete requirements we don't want anymore. // Update versions and indirect comments on requirements we want to keep. // If a requirement is in last{Direct,Indirect}Block with the wrong @@ -1430,10 +1482,6 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // correct block. // // Some blocks may be empty after this. Cleanup will remove them. - need := make(map[string]*Require) - for _, r := range req { - need[r.Mod.Path] = r - } have := make(map[string]*Require) for _, r := range f.Require { path := r.Mod.Path @@ -1446,10 +1494,10 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { r.setVersion(need[path].Mod.Version) r.setIndirect(need[path].Indirect) if need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { moveReq(r, lastIndirectBlock) } else if !need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { moveReq(r, lastDirectBlock) } } @@ -1736,8 +1784,7 @@ func removeDups(syntax *FileSyntax, exclude *[]*Exclude, replace *[]*Replace, to // Remove duplicate replacements. // Later replacements take priority over earlier ones. haveReplace := make(map[module.Version]bool) - for i := len(*replace) - 1; i >= 0; i-- { - x := (*replace)[i] + for _, x := range slices.Backward(*replace) { if haveReplace[x.Old] { kill[x.Syntax] = true continue diff --git a/vendor/golang.org/x/sync/semaphore/semaphore.go b/vendor/golang.org/x/sync/semaphore/semaphore.go index 040c5bc50..96a035aed 100644 --- a/vendor/golang.org/x/sync/semaphore/semaphore.go +++ b/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -24,7 +24,7 @@ func NewWeighted(n int64) *Weighted { } // Weighted provides a way to bound concurrent access to a resource. -// The callers can request access with a given weight. +// The callers can request access with a given non-negative weight. type Weighted struct { size int64 cur int64 @@ -32,10 +32,13 @@ type Weighted struct { waiters list.List } -// Acquire acquires the semaphore with a weight of n, blocking until resources +// Acquire acquires the semaphore with a non-negative weight of n, blocking until resources // are available or ctx is done. On success, returns nil. On failure, returns // ctx.Err() and leaves the semaphore unchanged. func (s *Weighted) Acquire(ctx context.Context, n int64) error { + if n < 0 { + panic("semaphore: n < 0") + } done := ctx.Done() s.mu.Lock() @@ -106,9 +109,12 @@ func (s *Weighted) Acquire(ctx context.Context, n int64) error { } } -// TryAcquire acquires the semaphore with a weight of n without blocking. +// TryAcquire acquires the semaphore with a non-negative weight of n without blocking. // On success, returns true. On failure, returns false and leaves the semaphore unchanged. func (s *Weighted) TryAcquire(n int64) bool { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() success := s.size-s.cur >= n && s.waiters.Len() == 0 if success { @@ -118,8 +124,11 @@ func (s *Weighted) TryAcquire(n int64) bool { return success } -// Release releases the semaphore with a weight of n. +// Release releases the semaphore with a non-negative weight of n. func (s *Weighted) Release(n int64) { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() s.cur -= n if s.cur < 0 { diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index ce4d7ab1e..21e2bfa39 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -1874,6 +1874,7 @@ func Dup2(oldfd, newfd int) error { //sys Dup3(oldfd int, newfd int, flags int) (err error) //sysnb EpollCreate1(flag int) (fd int, err error) //sysnb EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) +//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Eventfd(initval uint, flags int) (fd int, err error) = SYS_EVENTFD2 //sys Exit(code int) = SYS_EXIT_GROUP //sys Fallocate(fd int, mode uint32, off int64, len int64) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_386.go b/vendor/golang.org/x/sys/unix/syscall_linux_386.go index 506dafa7b..210d545c9 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_386.go @@ -20,7 +20,6 @@ func setTimeval(sec, usec int64) Timeval { // 64-bit file system and 32-bit uid calls // (386 default is 32-bit file system and 16-bit uid). -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64_64 //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go index d557cf8de..a9a52f231 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go index ecf92bfa2..54474c20f 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go @@ -44,7 +44,6 @@ func Seek(fd int, offset int64, whence int) (newoffset int64, err error) { // 64-bit file system and 32-bit uid calls // (16-bit uid calls are not always supported in newer kernels) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go index 173738077..e9f30db97 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go index a3fd1d0b8..6f09ca200 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go index 70963a95a..ca3b56597 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go index c218ebd28..54ba667b1 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go @@ -13,7 +13,6 @@ import ( func Syscall9(trap, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) (r1, r2 uintptr, err syscall.Errno) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Ftruncate(fd int, length int64) (err error) = SYS_FTRUNCATE64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go index e6c48500c..ce4628590 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go @@ -11,7 +11,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go index 7286a9aa8..33f7af380 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go index fc5543c5f..c658871e3 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go index 66f31210d..2c8587691 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go @@ -10,7 +10,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go index 11d1f1698..4964119af 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 9d72a6b73..5bb51d7ae 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -1359,6 +1359,7 @@ const ( FAN_UNLIMITED_MARKS = 0x20 FAN_UNLIMITED_QUEUE = 0x10 FD_CLOEXEC = 0x1 + FD_PIDFS_ROOT = -0x2712 FD_SETSIZE = 0x400 FF0 = 0x0 FIB_RULE_DEV_DETACHED = 0x8 @@ -1970,6 +1971,8 @@ const ( MADV_DONTNEED = 0x4 MADV_DONTNEED_LOCKED = 0x18 MADV_FREE = 0x8 + MADV_GUARD_INSTALL = 0x66 + MADV_GUARD_REMOVE = 0x67 MADV_HUGEPAGE = 0xe MADV_HWPOISON = 0x64 MADV_KEEPONFORK = 0x13 @@ -2114,7 +2117,7 @@ const ( MS_NOSEC = 0x10000000 MS_NOSUID = 0x2 MS_NOSYMFOLLOW = 0x100 - MS_NOUSER = -0x80000000 + MS_NOUSER = 0x80000000 MS_POSIXACL = 0x10000 MS_PRIVATE = 0x40000 MS_RDONLY = 0x1 @@ -3786,6 +3789,9 @@ const ( TCPOPT_TIMESTAMP = 0x8 TCPOPT_TSTAMP_HDR = 0x101080a TCPOPT_WINDOW = 0x3 + TCP_AO_KEYF_EXCLUDE_OPT = 0x2 + TCP_AO_KEYF_IFINDEX = 0x1 + TCP_AO_MAXKEYLEN = 0x50 TCP_CC_INFO = 0x1a TCP_CM_INQ = 0x24 TCP_CONGESTION = 0xd diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux.go b/vendor/golang.org/x/sys/unix/zsyscall_linux.go index 80f40e401..5788c2a58 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux.go @@ -700,6 +700,23 @@ func EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { + var _p0 unsafe.Pointer + if len(events) > 0 { + _p0 = unsafe.Pointer(&events[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func Eventfd(initval uint, flags int) (fd int, err error) { r0, _, e1 := Syscall(SYS_EVENTFD2, uintptr(initval), uintptr(flags), 0) fd = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go index 4def3e9fc..254f33988 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64_64, uintptr(fd), uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go index fef2bc8ba..27c05db1a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go index a9fd76a88..840d85bfc 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go @@ -213,23 +213,6 @@ func sendmsg(s int, msg *Msghdr, flags int) (n int, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN32, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go index 460065028..fe414498b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go index c8987d264..eb358ce05 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go index 921f43061..c437622f1 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset>>32), uintptr(offset), uintptr(length>>32), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go index 44f067829..bc4ca2558 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go index e7fa0abf0..5051435ce 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go index 8c5125675..33aa5418a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go index 7392fd45e..3bef8ef1d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go index 41180434e..fc1bd4e2c 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go index 40c6ce7ae..d78fe7dab 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go index 2cfe34adb..76dcf87d0 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go index 61e6f0709..2cf020f2b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go index 834b84204..527637623 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/windows/security_windows.go b/vendor/golang.org/x/sys/windows/security_windows.go index 6c955cea1..783621561 100644 --- a/vendor/golang.org/x/sys/windows/security_windows.go +++ b/vendor/golang.org/x/sys/windows/security_windows.go @@ -1109,17 +1109,53 @@ const ( ) // This type is the union inside of TRUSTEE and must be created using one of the TrusteeValueFrom* functions. +// +// Go pointers stored in a TrusteeValue must be pinned using [runtime.Pinner] +// for the lifetime of the TrusteeValue. type TrusteeValue uintptr +// TrusteeValueFromString is unsafe and should not be used. +// +// It returns a uintptr containing a reference to newly-allocated memory +// which will be freed by the garbage collector. +// There is no way for the caller to safely reference this memory. +// +// To create a [TrusteeValue] from a string, use: +// +// p, err := windows.UTF16PtrFromString(s) +// if err != nil { +// // handle error +// } +// +// // Pin the string for as long as it is used. +// var pinner runtime.Pinner +// pinner.Pin(p) +// defer pinner.Unpin() +// +// tv := TrusteeValue(unsafe.Pointer(p)) +// +// Deprecated: TrusteeValueFromString is unsafe and should not be used. func TrusteeValueFromString(str string) TrusteeValue { return TrusteeValue(unsafe.Pointer(StringToUTF16Ptr(str))) } + +// TrusteeValueFromSID returns a [TrusteeValue] referencing sid. +// +// The caller must pin sid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromSID(sid *SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(sid)) } + +// TrusteeValueFromObjectsAndSid returns a [TrusteeValue] referencing objectsAndSid. +// +// The caller must pin objectsAndSid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndSid(objectsAndSid *OBJECTS_AND_SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndSid)) } + +// TrusteeValueFromObjectsAndName returns a [TrusteeValue] referencing objectsAndName. +// +// The caller must pin objectsAndName using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndName(objectsAndName *OBJECTS_AND_NAME) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndName)) } diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index 9755bca9f..e6966b4c3 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -1728,11 +1728,15 @@ func (s *NTUnicodeString) String() string { // the more common *uint16 string type. func NewNTString(s string) (*NTString, error) { var nts NTString - s8, err := BytePtrFromString(s) + s8, err := ByteSliceFromString(s) if err != nil { return nil, err } - RtlInitString(&nts, s8) + // The source string plus its terminating NUL must fit within MAX_USHORT. + if len(s8) > MAX_USHORT { + return nil, syscall.EINVAL + } + RtlInitString(&nts, &s8[0]) return &nts, nil } diff --git a/vendor/golang.org/x/sys/windows/types_windows.go b/vendor/golang.org/x/sys/windows/types_windows.go index d2574a73e..75a50b316 100644 --- a/vendor/golang.org/x/sys/windows/types_windows.go +++ b/vendor/golang.org/x/sys/windows/types_windows.go @@ -169,6 +169,7 @@ const ( FORMAT_MESSAGE_ARGUMENT_ARRAY = 8192 FORMAT_MESSAGE_MAX_WIDTH_MASK = 255 + MAX_USHORT = 0xffff MAX_PATH = 260 MAX_LONG_PATH = 32768 diff --git a/vendor/golang.org/x/text/cases/context.go b/vendor/golang.org/x/text/cases/context.go index e9aa9e193..a28f45d7b 100644 --- a/vendor/golang.org/x/text/cases/context.go +++ b/vendor/golang.org/x/text/cases/context.go @@ -249,7 +249,7 @@ func upper(c *context) bool { return c.copy() } -// isUpper writes the isUppercase version of the current rune to dst. +// isUpper reports whether the current rune is in upper case. func isUpper(c *context) bool { ct := c.caseType() if c.info&hasMappingMask == 0 || ct == cUpper { diff --git a/vendor/golang.org/x/text/cases/map.go b/vendor/golang.org/x/text/cases/map.go index 0f7c6a14b..51a683092 100644 --- a/vendor/golang.org/x/text/cases/map.go +++ b/vendor/golang.org/x/text/cases/map.go @@ -774,7 +774,7 @@ func nlTitle(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' && c.src[c.pSrc] != 'i' { return title(c) @@ -794,7 +794,7 @@ func nlTitleSpan(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' { return isTitle(c) diff --git a/vendor/golang.org/x/text/unicode/norm/forminfo.go b/vendor/golang.org/x/text/unicode/norm/forminfo.go index f3a234e5f..b3cf5d9bd 100644 --- a/vendor/golang.org/x/text/unicode/norm/forminfo.go +++ b/vendor/golang.org/x/text/unicode/norm/forminfo.go @@ -121,8 +121,12 @@ func (p Properties) BoundaryAfter() bool { // // When all 6 bits are zero, the character is inert, meaning it is never // influenced by normalization. +// +// We set flags to 0x80 (high bit 7 unused in quick check data) to indicate an invalid rune. type qcInfo uint8 +func (p Properties) isInvalid() bool { return p.flags == 0x80 } + func (p Properties) isYesC() bool { return p.flags&0x10 == 0 } func (p Properties) isYesD() bool { return p.flags&0x4 == 0 } @@ -247,6 +251,9 @@ func (f Form) PropertiesString(s string) Properties { // to a Properties. See the comment at the top of the file // for more information on the format. func compInfo(v uint16, sz int) Properties { + if sz == 0 { + return Properties{flags: 0x80, size: 1} + } if v == 0 { return Properties{size: uint8(sz)} } else if v >= 0x8000 { @@ -254,7 +261,7 @@ func compInfo(v uint16, sz int) Properties { size: uint8(sz), ccc: uint8(v), tccc: uint8(v), - flags: qcInfo(v >> 8), + flags: qcInfo(v>>8) & 0x3f, } if p.ccc > 0 || p.combinesBackward() { p.nLead = uint8(p.flags & 0x3) diff --git a/vendor/golang.org/x/text/unicode/norm/iter.go b/vendor/golang.org/x/text/unicode/norm/iter.go index 417c6b268..3cc059224 100644 --- a/vendor/golang.org/x/text/unicode/norm/iter.go +++ b/vendor/golang.org/x/text/unicode/norm/iter.go @@ -376,16 +376,12 @@ func nextComposed(i *Iter) []byte { goto doNorm } prevCC = i.info.tccc - sz := int(i.info.size) - if sz == 0 { - sz = 1 // illegal rune: copy byte-by-byte - } - p := outp + sz + p := outp + int(i.info.size) if p > len(i.buf) { break } outp = p - i.p += sz + i.p += int(i.info.size) if i.p >= i.rb.nsrc { i.setDone() break diff --git a/vendor/golang.org/x/text/unicode/norm/normalize.go b/vendor/golang.org/x/text/unicode/norm/normalize.go index 4747ad07a..60b1511ca 100644 --- a/vendor/golang.org/x/text/unicode/norm/normalize.go +++ b/vendor/golang.org/x/text/unicode/norm/normalize.go @@ -148,7 +148,7 @@ func (f Form) IsNormalString(s string) bool { // patched buffer and whether the decomposition is still in progress. func patchTail(rb *reorderBuffer) bool { info, p := lastRuneStart(&rb.f, rb.out) - if p == -1 || info.size == 0 { + if p == -1 || info.isInvalid() { return true } end := p + int(info.size) @@ -225,7 +225,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { } fd := &rb.f if doMerge { - var info Properties + info := Properties{flags: 0x80, size: 1} // invalid rune if p < n { info = fd.info(src, p) if !info.BoundaryBefore() || info.nLeadingNonStarters() > 0 { @@ -235,7 +235,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { p = decomposeSegment(rb, p, true) } } - if info.size == 0 { + if info.isInvalid() { rb.doFlush() // Append incomplete UTF-8 encoding. return src.appendSlice(rb.out, p, n) @@ -314,7 +314,7 @@ func (f *formInfo) quickSpan(src input, i, end int, atEOF bool) (n int, ok bool) continue } info := f.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { // include incomplete runes return n, true @@ -379,7 +379,7 @@ func (f Form) firstBoundary(src input, nsrc int) int { // CGJ insertion points correctly. Luckily it doesn't have to. for { info := fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { return -1 } if s := ss.next(info); s != ssSuccess { @@ -424,7 +424,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { } fd := formTable[f] info := fd.info(src, 0) - if info.size == 0 { + if info.isInvalid() { if atEOF { return 1 } @@ -435,7 +435,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { for i := int(info.size); i < nsrc; i += int(info.size) { info = fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { return i } @@ -465,7 +465,7 @@ func lastBoundary(fd *formInfo, b []byte) int { if p == -1 { return -1 } - if info.size == 0 { // ends with incomplete rune + if info.isInvalid() { // ends with incomplete rune if p == 0 { // starts with incomplete rune return -1 } @@ -504,7 +504,7 @@ func lastBoundary(fd *formInfo, b []byte) int { func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { // Force one character to be consumed. info := rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { return 0 } if s := rb.ss.next(info); s == ssStarter { @@ -528,7 +528,7 @@ func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { break } info = rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { if !atEOF { return int(iShortSrc) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 2c60d9282..005d79a73 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -48,7 +48,7 @@ github.com/fatih/color # github.com/gdamore/encoding v1.0.1 ## explicit; go 1.9 github.com/gdamore/encoding -# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 +# github.com/gdamore/tcell/v3 v3.4.1 ## explicit; go 1.25.0 github.com/gdamore/tcell/v3 github.com/gdamore/tcell/v3/color @@ -175,27 +175,25 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.36.0 +# golang.org/x/mod v0.37.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile golang.org/x/mod/module golang.org/x/mod/semver -# golang.org/x/net v0.55.0 -## explicit; go 1.25.0 -# golang.org/x/sync v0.21.0 +# golang.org/x/sync v0.22.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup golang.org/x/sync/semaphore -# golang.org/x/sys v0.46.0 +# golang.org/x/sys v0.47.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.44.0 +# golang.org/x/term v0.45.0 ## explicit; go 1.25.0 golang.org/x/term -# golang.org/x/text v0.38.0 +# golang.org/x/text v0.40.0 ## explicit; go 1.25.0 golang.org/x/text/cases golang.org/x/text/encoding @@ -208,7 +206,7 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.45.0 +# golang.org/x/tools v0.47.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c From 46c1fa7db28d1b9399dfb6df2dcce76ab1865755 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 19:11:33 +0200 Subject: [PATCH 89/94] Don't let a leaked subprocess wedge the headless test runner When lazygit exits but leaves behind a subprocess that inherited its stderr pipe and has detached from the pty, cmd.Wait() blocks in awaitGoroutines waiting for that pipe to reach EOF -- which never happens while the straggler is alive. With no WaitDelay set, that wait is unbounded, so a single leaked process hangs the whole test binary until the 10-minute global timeout fires and panics. Worse, the timeout discards whatever lazygit wrote to stderr before exiting (a panic, a -race report), which is exactly the output needed to diagnose the failure. This surfaces under -race, where lazygit runs slow enough to widen the window for a spawned command to still be alive when lazygit quits, and it's a blocker for enabling the race detector on CI. Bound the wait with cmd.WaitDelay so Wait force-closes the pipe and returns ErrWaitDelay instead of hanging, surface the captured stderr as the error (falling back to the wait error when nothing was printed), and kill the child's process group on failure so a straggler can't linger into a later test or pile up across a run. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/clients/go_test.go | 39 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 11f6e754e..d3f15b88a 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -11,7 +11,9 @@ import ( "io" "os" "os/exec" + "syscall" "testing" + "time" "github.com/creack/pty" "github.com/jesseduffield/lazycore/pkg/utils" @@ -75,6 +77,17 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { stderr := new(bytes.Buffer) cmd.Stderr = stderr + // If lazygit exits but leaves behind a subprocess that inherited its stderr + // pipe, cmd.Wait blocks waiting for that pipe to reach EOF for as long as the + // subprocess stays alive. Unbounded, that hangs the whole test binary until + // its global timeout fires, and the timeout throws away whatever lazygit + // wrote to stderr before exiting (a panic, a -race report) -- the very output + // needed to diagnose the failure. WaitDelay caps the wait: once the process + // has exited, Wait gives the stderr goroutine at most this long to drain, + // then closes the pipe and returns ErrWaitDelay, so the captured stderr + // surfaces as the test error instead of being lost. + cmd.WaitDelay = 5 * time.Second + // these rows and columns are ignored because internally we use tcell's // simulation screen. However we still need the pty for the sake of // running other commands in a pty. @@ -83,12 +96,32 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { return -1, err } + // pty.StartWithSize starts lazygit in its own process group, so we can signal + // the whole group at once. Capture the id now, while the process is alive: + // once Wait has reaped it we can no longer look it up. + pgid, pgidErr := syscall.Getpgid(cmd.Process.Pid) + _, _ = io.Copy(io.Discard, f) - if cmd.Wait() != nil { + waitErr := cmd.Wait() + + // On any failure -- including a WaitDelay expiry caused by a leaked + // subprocess -- kill the whole process group so a straggler can't linger and + // wedge a later test or pile up across a CI run. Best effort: usually the + // group is already gone (ESRCH), and a subprocess that called setsid to + // detach into its own group is out of reach, but WaitDelay still unblocks us. + if waitErr != nil && pgidErr == nil { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + + if waitErr != nil { _ = f.Close() - // return an error with the stderr output - return cmd.Process.Pid, errors.New(stderr.String()) + // Prefer lazygit's own stderr as the error; fall back to the wait error + // itself (e.g. ErrWaitDelay) when it exited without printing anything. + if stderr.Len() > 0 { + return cmd.Process.Pid, errors.New(stderr.String()) + } + return cmd.Process.Pid, waitErr } return cmd.Process.Pid, f.Close() From 7fce58b09bc7b9bb61cbd8c3950579121a0bb832 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:55:36 +0200 Subject: [PATCH 90/94] Scale the integration test watchdog up under the race detector The integration test watchdog fails a test if its recording takes longer than 40 seconds. Under the race detector everything runs several times slower, so legitimately slow tests (e.g. a conflicting interactive rebase) blow that budget and fail even though nothing is actually stuck. Key the timeout off a build-tag constant: the `race` tag is set automatically when the binary is built with -race, so a race build gets a 5x-longer budget while a normal build is unchanged, and the two can't drift apart the way a runtime flag would. The base 40s stays in one place; only the multiplier varies by build. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/test_mode.go | 5 +++-- pkg/gui/test_timeout_norace.go | 5 +++++ pkg/gui/test_timeout_race.go | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 pkg/gui/test_timeout_norace.go create mode 100644 pkg/gui/test_timeout_race.go diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index d6893c92d..0b24bb8cd 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -45,9 +45,10 @@ func (gui *Gui) handleTestMode() { }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { + timeout := 40 * time.Second * testTimeoutMultiplier go utils.Safe(func() { - time.Sleep(time.Second * 40) - log.Fatal("40 seconds is up, lazygit recording took too long to complete") + time.Sleep(timeout) + log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout) }) } } diff --git a/pkg/gui/test_timeout_norace.go b/pkg/gui/test_timeout_norace.go new file mode 100644 index 000000000..7f924ea71 --- /dev/null +++ b/pkg/gui/test_timeout_norace.go @@ -0,0 +1,5 @@ +//go:build !race + +package gui + +const testTimeoutMultiplier = 1 diff --git a/pkg/gui/test_timeout_race.go b/pkg/gui/test_timeout_race.go new file mode 100644 index 000000000..7d633def3 --- /dev/null +++ b/pkg/gui/test_timeout_race.go @@ -0,0 +1,10 @@ +//go:build race + +package gui + +// The race detector makes everything run several times slower, so the +// recording watchdog needs a correspondingly longer timeout; otherwise it +// fires on tests that are merely slow under -race rather than actually stuck. +// The `race` build tag is set automatically when the binary is built with +// -race, so this can't drift out of sync with the actual build. +const testTimeoutMultiplier = 4 From 334daccfab6e080df95b94678d33e607b106a850 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 15:29:28 +0200 Subject: [PATCH 91/94] Increase integration test timeout to 30 minutes Go's default 10-minute timeout was enough for running integration tests normally (both locally and on CI), but with race detection turned on they can take much longer to run. Increase the timeout unconditionally to 30 minutes; we don't bother making a distinction between race vs. normal, because a longer timeout doesn't hurt (I can't recall having hit the global timeout ever; and we still have the per-test watchdog that kills an individual test after 40s). --- justfile | 2 +- scripts/run_integration_tests.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/justfile b/justfile index 64d9d1ee2..0851179b9 100644 --- a/justfile +++ b/justfile @@ -40,7 +40,7 @@ lint: ./scripts/gofumpt-check.sh ./scripts/golangci-lint-shim.sh run -e2e-test-command := "go test pkg/integration/clients/*.go" +e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go" # Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI. e2e *args: diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 579e6d77c..1dadeec97 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -19,7 +19,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # hacky. To capture the coverage data for the test runner we pass the test.gocoverdir positional # arg, but if we do that then the GOCOVERDIR env var (which you typically pass to the test binary) will be overwritten by the test runner. So we're passing LAZYGIT_COCOVERDIR instead # and then internally passing that to the test binary as GOCOVERDIR. - go test -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" + go test -timeout 30m -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" EXITCODE=$? # We're merging the coverage data for the sake of having fewer artefacts to upload. @@ -29,7 +29,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then rm -rf /tmp/code_coverage mv /tmp/code_coverage_merged /tmp/code_coverage else - go test pkg/integration/clients/*.go + go test -timeout 30m pkg/integration/clients/*.go EXITCODE=$? fi From 840a66e7333ae412003b33cd9ff3799a251e92f9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 13:09:40 +0200 Subject: [PATCH 92/94] Run the integration tests under the race detector on CI Add one extra integration-tests job that runs the whole suite under the race detector. A `race` matrix dimension (default false) plus an include entry adds a single git-latest job with LAZYGIT_RACE_DETECTOR set; races live in lazygit's own Go code rather than in git, so one git version is enough, and using latest skips the git-build steps. The race job skips coverage collection: it's redundant with the non-race latest job and would only slow the -race build down further. --- .github/workflows/ci.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e7538b0d..d14778483 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,16 @@ jobs: - 2.38.2 # first version that supports the rebase.updateRefs config - 2.44.0 - latest # We rely on github to have the latest version installed on their VMs + race: + - false + # Additionally run the whole suite once under the race detector. Data + # races live in lazygit's own Go code rather than in git, so a single + # git version is enough; use the latest to skip the git-build steps. + include: + - git-version: latest + race: true runs-on: ubuntu-latest - name: "Integration Tests - git ${{matrix.git-version}}" + name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}" env: GOFLAGS: -mod=vendor steps: @@ -92,12 +100,20 @@ jobs: run: git --version - name: Test code env: - # See https://go.dev/blog/integration-test-coverage - LAZYGIT_GOCOVERDIR: /tmp/code_coverage + # See https://go.dev/blog/integration-test-coverage. The race variant + # skips coverage: it's redundant with the non-race latest job and + # would only slow the -race build down further. Leaving the dir unset + # makes run_integration_tests.sh take its non-coverage path. + LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }} + # Only set for the race variant. The race detector needs cgo; it's on + # by default on the Linux runner, but we set it explicitly to be safe. + LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }} + CGO_ENABLED: ${{ matrix.race && '1' || '' }} run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh - name: Upload code coverage artifacts + if: ${{ !matrix.race }} uses: actions/upload-artifact@v7 with: name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }} From 03a914c04c713df8b7dbb504f2154185047675cb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:28:19 +0200 Subject: [PATCH 93/94] Dump goroutine stacks when the test watchdog fires The watchdog only log.Fatal'd with a message, so a hung test told us that it timed out but not where it was stuck -- useless for diagnosing an intermittent deadlock under the race detector. Dump all goroutine stacks to stderr first (the harness surfaces this process's stderr on failure), turning a bare timeout into an actionable stack trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/test_mode.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 0b24bb8cd..2644e989b 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -3,6 +3,7 @@ package gui import ( "log" "os" + "runtime/pprof" "time" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -48,6 +49,10 @@ func (gui *Gui) handleTestMode() { timeout := 40 * time.Second * testTimeoutMultiplier go utils.Safe(func() { time.Sleep(timeout) + // Dump all goroutine stacks before dying, so a hung test shows + // where it got stuck rather than just that it timed out. The + // test harness surfaces this process's stderr on failure. + _ = pprof.Lookup("goroutine").WriteTo(os.Stderr, 2) log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout) }) } From b6b5436d571d377af1e487dee871fad101a46a25 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:28:19 +0200 Subject: [PATCH 94/94] Log per-test durations during integration tests To spot slow or anomalous tests across CI runs, record each test's run duration when LAZYGIT_TEST_TIMING is set (to a file path); run_integration_tests.sh prints them at the end, sorted by slowest first. CI sets it for all integration jobs. The harness appends to a file rather than writing to stdout/stderr because `go test` captures those and only surfaces them with -v, which would drown the signal in every test's verbose logs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 +++ pkg/integration/clients/go_test.go | 4 +++- pkg/integration/components/runner.go | 34 ++++++++++++++++++++++++++++ scripts/run_integration_tests.sh | 8 +++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14778483..c6d2fdf6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: # by default on the Linux runner, but we set it explicitly to be safe. LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }} CGO_ENABLED: ${{ matrix.race && '1' || '' }} + # Append each test's duration to this file; run_integration_tests.sh + # prints the slowest at the end, to spot slow/anomalous tests. + LAZYGIT_TEST_TIMING: /tmp/test_timings.txt run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index d3f15b88a..4c1faa557 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -30,6 +30,7 @@ func TestIntegration(t *testing.T) { parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != "" + logTimingsPath := os.Getenv("LAZYGIT_TEST_TIMING") // LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory // is defined, go binaries built with the -cover flag will write coverage files to // to it. @@ -58,7 +59,8 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 1, + MaxAttempts: 1, + LogTimingsPath: logTimingsPath, }) assert.NoError(t, err) diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 5640c3e70..78cb5439f 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "sync" + "time" lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -24,6 +26,12 @@ type RunTestArgs struct { CodeCoverageDir string InputDelay int MaxAttempts int + // If set, each test's run duration is appended to this file (as + // " "). run_integration_tests.sh prints the slowest at + // the end, so slow or anomalous tests can be spotted across CI runs. We + // write to a file rather than stdout/stderr because `go test` captures + // those and only shows them with -v. Empty disables it. + LogTimingsPath string } // This function lets you run tests either from within `go test` or from a regular binary. @@ -47,6 +55,11 @@ func RunTests(args RunTestArgs) error { return err } + // Start each run with a fresh timings file (see RunTestArgs.LogTimingsPath). + if args.LogTimingsPath != "" { + _ = os.Remove(args.LogTimingsPath) + } + for _, test := range args.Tests { args.TestWrapper(test, func() error { paths := NewPaths( @@ -99,7 +112,11 @@ func runTest( return err } + start := time.Now() pid, err := args.RunCmd(cmd) + if args.LogTimingsPath != "" { + logTestTiming(args.LogTimingsPath, test.Name(), time.Since(start)) + } // Print race detector log regardless of the command's exit status if args.RaceDetector { @@ -112,6 +129,23 @@ func runTest( return err } +// timingsMutex serializes appends to the timings file, since tests run in +// parallel. +var timingsMutex sync.Mutex + +func logTestTiming(path, name string, duration time.Duration) { + timingsMutex.Lock() + defer timingsMutex.Unlock() + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + + fmt.Fprintf(f, "%.2f %s\n", duration.Seconds(), name) +} + func prepareTestDir( test *IntegrationTest, paths Paths, diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 1dadeec97..2bf010f19 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -37,4 +37,12 @@ if test -f ~/.gitconfig.lazygit.bak; then mv ~/.gitconfig.lazygit.bak ~/.gitconfig fi +# If per-test timings were collected (LAZYGIT_TEST_TIMING points at the file the +# harness appends to), print them sorted by slowest first so they show up in the +# CI log. +if [ -n "$LAZYGIT_TEST_TIMING" ] && [ -f "$LAZYGIT_TEST_TIMING" ]; then + echo "Test timings (seconds):" + sort -rn "$LAZYGIT_TEST_TIMING" +fi + exit $EXITCODE