Commit graph

7789 commits

Author SHA1 Message Date
Stefan Haller eb95ae15f3 Capture the custom-patch handlers' commit reads on the UI thread
These handlers dispatch their rebase to a worker via WithWaitingStatus
but read Model().Commits (and, for move-to-selected-commit, the selected
line index) from inside that worker, racing the UI thread's model
writes. Read them on the UI thread before dispatching and close over the
results.

getPatchCommitIndex stays as-is: moving its call out of the worker makes
its own Model().Commits read UI-thread-bound too, so the identical copy
in patch_building_controller.go needs no matching signature change.

The two pull-patch-into-new-commit handlers still push a context and
close the commit-message panel from the worker; those writes are a
separate concern, left for a follow-up.
2026-07-07 18:10:45 +02:00
Stefan Haller 5162a768eb Guard every refresh's entry point, not just the commits scope
With every scope's worker reads now captured on the UI thread and every
worker caller on RefreshFromWorker, the debug entry-point assertion no longer
needs to be scoped to the commits refresh. Move it to the top of
performRefresh so it guards every refresh regardless of which scopes it
touches, and drop the per-scope gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller fd6b20847a Capture the files, reflog, branches and stash refresh inputs
The remaining refresh scopes each still read model, context, and mode state
directly on their worker, racing the UI thread — the same class of race the
commits refresh had:

- files reads Model.Files (to detect resolved conflicts and drive the
  auto-stage) and the Files context's ForceShowUntracked;
- reflog reads the existing reflog slices (for the incremental fetch),
  Model.HashPool and the filtering path/author;
- branches reads Model.MainBranches and the previous branches (for the
  BehindBaseBranch carry-over);
- stash reads the filtering path.

Gather each scope's inputs into an immutable snapshot on the UI thread (via
captureOnUIThread) before dispatching the git work, and have the refresh
compute from the snapshot — for branches, threaded through both the immediate
and the recency-sorted startup loads, which share one snapshot (the
BehindBaseBranch carry-over is identical either way). Status, tags and
worktrees read nothing UI-owned, so they're left alone.

For the snapshots to actually run on the UI thread, the worker callers that
reach these scopes must announce themselves: convert the submodule
operations, the submodule stash-and-reset, and the background files poller
to RefreshFromWorker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 3e5c99e1e4 Make the started-in-lazygit and startup-stage flags atomic
GuiRepoState.mergeOrRebaseStartedInLazygit and StartupStage are plain
fields, but they're written and read from worker goroutines: the former
from both the files refresh and the merge/rebase result path (which runs on
a worker for the async callers), the latter from the reflog/branches load as
it transitions the startup stage. Those are data races.

Make both atomic, like Branch.BehindBaseBranch. They're leaf flags, not
mutexes guarding model or view state, so an atomic is the natural fit and
keeps the merge/rebase result path out of this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 56989922e4 Capture the remotes/sub-commits/commit-files/rebase-commits inputs
These four refreshes each read model, context, and mode state directly on
their worker — the same class of race the commits refresh had:

- remotes reads the selected remote (Contexts().Remotes.GetSelected), needed
  to keep the remote-branches selection valid;
- sub-commits reads the SubCommits ref/limit/divergence, the filtering
  path/author, and Model.MainBranches/HashPool;
- commit-files reads the diff endpoints (CommitFiles from/to and the diffing
  args);
- rebase-commits reads Model.HashPool/Commits.

Give each the same treatment as commits: gather its inputs into an
immutable snapshot on the UI thread (via captureOnUIThread, inline for a
UI-thread refresh, hopped for a worker one) before dispatching the git work,
and have the refresh compute from the snapshot. The commit-files re-init
inside the commits refresh captures its endpoints in the bounce, right after
ReInit sets them, before dispatching to the worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 988d04bda9 Assert a refresh uses the entry point matching its goroutine
Now that every commits-reaching refresh issued from a worker goes through
RefreshFromWorker, guard the choice: in debug builds, panic if a refresh was
issued from the UI thread as RefreshFromWorker or from a worker as Refresh.

The caller's own goroutine is recorded at the top of performRefresh, before
a BLOCK_UI refresh dispatches onto the UI thread, so the check holds for
every mode rather than being fooled by BLOCK_UI. It's scoped to the commits
refresh for now, the only converted scope; once the rest are converted the
guard can move up to cover every refresh unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 558fd2c9d3 Route merge/rebase result handling to the right refresh entry point
CheckMergeOrRebaseWithRefreshOptions refreshes after a merge/rebase step,
and until now always via the UI-thread Refresh. Most of its callers are on a
worker (the WithWaitingStatus/WithInlineStatus merge, squash-merge, rebase,
pull, amend, drop, and patch-move handlers), so that refresh reads the
commits scope off the UI thread — the race the previous commit addresses for
everything else.

Split it: the default is for worker callers and refreshes via
RefreshFromWorker; a new CheckMergeOrRebaseWithRefreshOptionsFromUIThread is
for the handlers that run the step synchronously on the UI thread
(WithWaitingStatusSync, kept sync so rapid key presses batch): move up/down,
revert, squash-fixups, cherry-pick paste, and patch-discard.

The two share a private impl carrying which thread the caller is on, and the
auto-skip recursion (genericMergeCommandImpl for an empty commit) threads it
through so the follow-up step refreshes on the same thread. The
merge-and-commit refresh in SquashMergeCommitted, also on a worker, moves to
RefreshFromWorker to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 080542c9fb Capture the commits refresh's inputs on the UI thread
A commits refresh does its git work on a worker and then reads the model,
the contexts, and the modes for that work directly from there:
LocalCommits.GetSelectionRangeAndMode/GetLimitCommits/GetShowWholeGitGraph,
Model.Commits/MainBranches/HashPool, the filtering path/author. Those are
owned by the UI thread, which is concurrently running the cursor and render
code, so the reads race it — the dominant, confirmed source of the
commits-scope flakes (the startup ClampSelection vs GetSelectionRangeAndMode
race, for one).

Gather them into an immutable capturedCommitState on the UI thread, before
the git work is dispatched, and have refreshCommitsWithLimit compute from
that snapshot. UI-thread callers capture inline; worker callers can't (a
SYNC/BLOCK_UI refresh parks the UI thread at wg.Wait, so hopping from a
scope sub-worker would deadlock), so the capture is lifted out of the scope
worker into the refresh orchestration, and worker callers announce
themselves with a new RefreshFromWorker entry point that hops the capture to
the UI thread and blocks for it (OnUIThreadAndWait). BLOCK_UI runs the whole
refresh on the UI thread regardless of the caller, so it captures inline
too.

Every refresh issued from a worker that reaches the commits (or branches,
which pulls in commits) scope is converted: the fast-forward, branch/tag
delete, worktree remove/detach, push, reword-via-rebase, author edits,
custom-command, hard-reset-with-autostash, reset-to-ref, fetch-and-checkout,
gpg-stream, post-fetch, and external-change-poller refreshes, plus the
branch checkout and move-commits-to-new-branch refreshes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller 1def541acb Add IsUIThread and OnUIThreadAndWait to gocui
The next commits move refresh workers to read UI-thread-owned state (the
model, contexts, selection) on the UI thread rather than off it. Two
primitives support that:

- OnUIThreadAndWait runs a function on the main event loop and blocks the
  caller until it has run, so a worker can read that state without racing.
  OnUIThreadAndWaitBackground is the same for background routines, whose
  work must not count towards the program being busy.
- IsUIThread reports whether the caller is on the main event loop, for a
  debug-only assertion that a refresh was issued from the thread it claims.
  It records the main loop's goroutine id in MainLoop and compares via
  goid, so it's promoted from an indirect to a direct dependency.

goid is used only by that debug assertion, never to drive production
control flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller f0ea537956 Fetch pull requests using the freshly-loaded branches and remotes
The PR fetch needs the current branches (for their upstreams) and
remotes to know what to query. It read them from Model().Branches /
Model().Remotes on its own worker, after waiting on branchesAndRemotesWg
for the branches and remotes refreshes to finish.

That wait no longer guarantees fresh data: those refreshes now write the
model in a bounce onto the UI thread, and Done() fires before the bounce
has been processed. So the fetch read the pre-refresh lists — most
visibly, checking out a branch that has a PR wouldn't show that PR until
the next refresh, because the fetch queried the old branch set.

Have refreshBranches / refreshReflogAndBranches / refreshRemotes return
what they loaded, stash it in locals in Refresh, and hand it to the
fetch. The wait on branchesAndRemotesWg orders the fetch after both
loads have stored their slices, so it fetches against exactly the
branches and remotes that were just loaded, with no model read on the
worker. The previous commit guarantees both are always in scope when
pull requests are, so no fallback is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 23cfa9b070 Also refresh branches and remotes when refreshing pull requests
The pull-request fetch queries GitHub for the tracking branches'
upstreams against the configured remotes. It therefore depends on the
branches and remotes being up to date; a refresh that asks for pull
requests but not for those (e.g. checking out a branch) would fetch
against a stale branch/remote list — for instance missing the PR of the
branch just checked out.

Expand the scope so pull requests always co-refresh branches and
remotes. This also sets up the next commit to hand the freshly-loaded
branches and remotes straight to the fetch, instead of reading them
back from the model (which, now that those writes are bounced onto the
UI thread, would be stale on the worker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 7c4d8045f9 Clamp the commit-file tree selection when the tree is rebuilt
CommitFileTreeViewModel embedded the low-level tree's SetTree, which
rebuilds the node list without touching the cursor. So after a shrinking
rebuild (e.g. moving a patch out into the index removes a file), the
selection index could be left past the end of the tree. GetSelectedItems
then indexes out of range and returns a nil node, which segfaults callers
such as canEditFiles when the options map is rendered during layout.

Override SetTree to ClampSelection after the rebuild. Unlike
FileTreeViewModel we deliberately don't also re-find the selected node by
path: that walk lands on the containing directory when a file is removed
from a dir that then collapses, whereas keeping the clamped index lands
on the sibling file (see discard_old_file_changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller bd6081d601 Select the checked-out branch via a refresh intent, not off-thread
Operations that check something out (checkout, create branch, move
commits to a new branch, fetch-and-checkout) selected the newly
checked-out branch by calling SelectFirstBranchAndFirstCommit() before
the refresh and passing KeepBranchSelectionIndex so the refresh wouldn't
override it. That set the selection directly, usually from a worker
goroutine (WithWaitingStatus/WithInlineStatus). Now that the refresh's
own selection write is bounced onto the UI thread, the two writes could
land in either order, and under load the refresh's "restore the
previously-selected branch" write would win — leaving the old branch
selected instead of the new one (flaky
move_commits_to_new_branch_from_base_branch).

Replace it with declarative selection intents applied inside the
refresh's own bounce, so the selection is set on the UI thread and
atomically with the list write (no off-thread write, and no BLOCK_UI
needed to avoid a flicker):

- BranchSelection: SelectCheckedOutBranch selects the checked-out branch
  (top of the list). The default, KeepBranchSelectionByName, restores
  the previously-selected branch by name as before. This replaces the
  KeepBranchSelectionIndex bool.
- CommitSelection: SelectHeadCommit (already existed) for the commit.
- SelectTopReflogCommit selects the top reflog entry, since a checkout
  adds a new entry there (reflog/checkout relies on this).

SelectFirstBranchAndFirstCommit is gone. The previously-selected branch
is now read at the top of the branches bounce, before the list is
overwritten, so that read moves onto the UI thread too.

fetchAndCheckout's refresh changes from ASYNC to SYNC so its
post-refresh focus switch can run in Then on the UI thread; SYNC keeps
the inline fetch spinner spinning (only BLOCK_UI would freeze it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 5414daf492 Exclude toast rendering from the busy query
A toast keeps a foreground spinner task alive for its whole lifetime
(~2-4s): showing one calls renderAppStatus, whose OnWorker loop runs
until the status string clears. With the repo-switch guard in place that
made the guard's own "can't switch, operation in progress" toast keep
Busy() true, so the next escape/switch was refused until the toast
faded — you had to wait it out.

Render toasts in the background, like view-buffer content: a toast is a
transient notification, not lazygit driving an operation, so a switch
during one is fine. A real operation that shows a toast still keeps its
own foreground task busy independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 56932abe06 Refuse a repo switch while a foreground operation is in flight
Switching repos reassigns gui.git and the process cwd; doing it while a
foreground git operation (rebase/commit/push/…) is mid-flight would run
that operation's remaining commands against the wrong repo. The same
applies while the refresh an operation triggers is still settling: its
model writes are generation-guarded, but the client-side Then/OnUIThread
callbacks that run after it aren't, and shouldn't run against a repo that
changed underneath them.

Refuse the switch (with a toast) whenever gocui reports a busy foreground
task. DispatchSwitchTo carries the guard for the simple callers. The
callers that do work before the switch check up front instead, so a
refused switch doesn't leave that work half-done: worktree creation
checks before creating (its own waiting-status spinner would otherwise
make the query busy and refuse its own switch); submodule-enter and the
recent-repos menu check before mutating the repo-path stack (pushing /
clearing it); and escape-to-parent (SwitchToParentRepo) checks before
popping it, so a refusal doesn't consume the entry and strand the user
with nowhere to escape back to. All then call the unguarded switchTo,
which is safe because their own operation is complete by then.
2026-07-07 18:09:33 +02:00
Stefan Haller 8655d3f5a5 Exclude view-buffer render tasks from the busy query
The repo-switch busy query must not count view-buffer content rendering:
those tasks paint a view rather than drive a git operation, so leaving
one running across a switch is harmless (the switch's own refresh
re-renders). More importantly, they fire on nearly every focus/selection
change — including the context activation that runs right before a
menu/prompt confirmation handler (e.g. confirming worktree creation).
A synchronous busy check in such a handler would otherwise see that
render and make the very switch the handler is about to request refuse
itself.

Route ViewBufferManager's tasks through a new gocui NewBackgroundTask so
they're tracked for idle detection but excluded from the busy query. The
task "background" flag now covers two kinds of non-blocking work: the
background routines (and their refreshes) tagged earlier, and view
rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller d95900ccd0 Tag background routines and their refreshes as background tasks
For the busy query to be usable as a repo-switch guard it has to be
false while the ongoing background routines run, or a switch would be
refused every time a background fetch or files refresh happened to be in
flight. Mark that work as background so it's excluded from the query.

The background routine dispatch in goEvery becomes OnWorkerBackground,
and the auto-fetch waiting status renders its spinner through the
background variants. Within a refresh, the background flag (which
Refresh already carries as options.Background, and which the files path
already threaded) is now threaded through every place that enqueues a
task: the async scope workers, the model-write bounces
(onUIThreadUnlessRepoChanged), refreshView, the staging bounce, the
Then dispatch, and the branch-loader's behind-count worker. Two
single-caller chains reached by a background files refresh get the flag
too: MergeConflictsHelper.EscapeMerge and BranchesHelper.
AutoForwardBranches (whose follow-up refresh must stay background when
triggered by the background fetch).

Nothing gates on the busy query yet, so this is behavior-preserving;
background tasks still count as busy for the test idle-listener, which
looks at every task regardless of the background flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller e352cafd43 Add background tasks and a synchronous busy query to gocui
Repo-switch safety needs to answer, synchronously on the UI thread,
"is any foreground work in flight right now?" so it can refuse a switch
that would run against a repo about to be swapped out. gocui already
tracks a task per OnWorker/Update for the test idle-listener; extend
that.

Tasks gain a background flag: background tasks (the ongoing routines
like auto-fetch, and the refreshes they trigger) don't count towards
busy, because their model writes are already guarded against a
concurrent switch by the repo generation. Add OnWorkerBackground,
UpdateBackground and UpdateContentOnlyBackground (plus the gui-layer
OnUIThreadBackground / OnUIThreadContentOnlyBackground / OnWorkerBackground
on IGuiCommon) so the few background call sites can opt in without
touching the hundreds of foreground callers.

TaskManager.hasBusyForegroundTaskExcept answers the query; Gui.Busy()
wraps it, excluding the event currently being processed (recorded as
currentTask) so a handler asking the question doesn't count itself.

Nothing gates on Busy() yet; this is the mechanism only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller cf7c3d82e6 Run the repo switch on the UI thread
DispatchSwitchTo wrapped its whole body in WithWaitingStatus, so the
switch ran on a worker: it chdirs, reassigns gui.git, and swaps gui.State
(in resetState), all of which the UI thread also reads. The generation
guard prevents the refresh-in-flight logical corruption but not this
pointer data race on gui.State.

Run the switch synchronously on the UI thread instead. Every caller is
already a UI-thread handler except NewWorktreeCheckout, which must create
the worktree (git work) on a worker first; it now dispatches only the
switch via OnUIThread. The heavy data loading still happens
asynchronously via the refresh that onNewRepo triggers, so the
synchronous part is small (a couple of git rev-parse plus direnv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 3103fe97ea Replace RefreshingBranchesMutex with a branch-load sequence guard
This removes the last refresh mutex. RefreshingBranchesMutex wasn't
guarding a data race (Branch.BehindBaseBranch is atomic, and every model
write is now bounced onto the UI thread); it was serializing the two
branch loads that race at the INITIAL startup stage — an immediate one
sorted without the reflog, and an async one that loads the reflog and
sorts by recency — so that the recency-sorted write landed last and won.
That serialization was never a real guarantee, only "very likely": it
relied on the immediate load acquiring the lock before the async load,
which had to load the reflog first.

Instead, each branch load takes a monotonically increasing sequence
number, and its bounce drops the write if a later-started load has
already applied. Combined with the preceding commit (immediate load runs
before the async one is spawned), this is an actual guarantee: the
immediate non-recency load always has a lower sequence than its recency
async partner, so the highest sequence number is always held by a
recency-sorted load, and highest-wins converges on recency ordering —
even if more refreshes fire during the INITIAL window, since each
refresh's async out-sequences its own immediate.

The guard also subsumes what the mutex gave post-startup: a slow, stale
refresh's bounce can no longer clobber a newer refresh's branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 6d8ab1d063 Run the immediate startup branch refresh before spawning the async one
At the INITIAL startup stage two branch refreshes happen: an immediate
one sorted by whatever reflog we have (empty, so not by recency), and an
async one that loads the reflog first and re-sorts by recency. Until now
the async one was spawned first and the immediate one ran afterwards;
this inverts that so the immediate refresh runs before the async one is
spawned.

With RefreshingBranchesMutex still in place this is behavior-preserving
(the mutex serializes the two either way). It's a preparatory step for
replacing that mutex with a branch-load sequence guard: running the
immediate refresh first establishes a happens-before relation between
the two loads' sequence numbers, so the recency-sorted one is guaranteed
the higher sequence.

This also lets refreshReflogCommitsConsideringStartup fold into
refreshReflogAndBranches, whose two-phase logic is now all in one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 805738034f Remove refresh mutexes made redundant by bouncing
Now that every refresh scope writes its model updates on the UI thread
via onUIThreadUnlessRepoChanged, the per-scope mutexes that used to
serialize concurrent worker-goroutine access are redundant:
Model().Commits, .SubCommits, .Authors, the status view content, and
.PullRequests/.PullRequestsMap are all now written only on the UI
thread, and their readers already ran there. setSubCommits only existed
to take the lock, so it's inlined to match refreshSubCommitsWithLimit,
which writes Model().SubCommits directly.

The worker phases still *read* some of these fields (the commit
selection range, MergeRebasingCommits), but those reads race a
concurrent refresh's bounced write regardless of the mutex — the write
happens in the bounce, outside the locked region — so the mutex never
protected them. That residual read race belongs to the broader -race
effort, not to these locks.

RefreshingBranchesMutex is deliberately kept. It is load-bearing for a
reason unrelated to data races: at the INITIAL startup stage two
refreshBranches run concurrently — an immediate one with an empty
reflog (non-recency order) and an async one with the freshly-loaded
reflog (recency order). The mutex serializes them so the recency write's
bounce is enqueued last and wins. Without it the stale non-recency write
can land last, reordering the branches list (caught by the recency-sort
e2e tests: cherry_pick/*, branch/rebase_*).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller f7a61443fa Bounce BRANCHES model updates onto the UI thread
refreshBranches now loads the branches (and worktrees) on the worker and
writes Model.Branches, the pull-requests map, Model.Worktrees, and the
restored branch selection in an onUIThreadUnlessRepoChanged bounce. The
selection restore and rebuildPullRequestsMap run in the bounce so they
see the branches we just wrote; the LocalCommits re-render (for branch
head visualization) moves into the same bounce.

refreshStatus is adjusted to read the checked-out branch and the linked
worktree name inside its bounce rather than on the worker: both derive
from models (Branches, Worktrees) that are now written via bounces, so
reading them on the worker would format the status from stale values —
which showed up as the status line dropping the "(worktree)" suffix right
after entering a submodule or switching worktrees. The git work
(WorkingTreeState) stays on the worker.

Two callers that read the branches model right after a SYNC branches
refresh move their reads into Then:
- BranchesHelper.PostFetchRefresh: AutoForwardBranches reads Model.Branches,
  so it runs in Then (preserving that a fetch error is still returned to
  the caller and that background auto-forward errors aren't surfaced as a
  popup).
- BranchesController rename: the re-select-by-name loop runs in Then.

RefreshingBranchesMutex is left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 549df17279 Bounce COMMITS model updates onto the UI thread
refreshCommitsWithLimit now loads the commits, working-tree state and
bisect info on the worker and writes them all — Model.Commits,
Model.BisectInfo, Model.WorkingTreeStateAtLastCommitRefresh,
Model.CheckedOutBranch, the authors, and the restored commit selection —
in a single onUIThreadUnlessRepoChanged bounce. The selection restore
(SelectHeadCommit / KeepCommitSelectionByHash) has to run in the bounce
because it reads the freshly-loaded commits; the FocusLine scroll is
enqueued from within the bounce so it still runs after refreshView's
re-render, as before.

refForLog no longer writes Model.BisectInfo as a side effect; it returns
the bisect info it read, and the bounce writes it, keeping that model
write on the UI thread. No caller reads Model.BisectInfo synchronously
after a refresh (the bisect controller reads Git().Bisect.GetInfo()
directly), so this is safe.

refreshCommitsAndCommitFiles's post-refresh re-init of the commit files
context depends on that restored selection, so it reads the selection in
a bounce and dispatches the commit-files git work back to a worker.

LocalCommitsMutex / AuthorsMutex are left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 4c3f8b51ea Bounce PULL_REQUESTS model updates onto the UI thread
refreshGithubPullRequests and setGithubPullRequests now do their network
work on the worker and write Model.PullRequests / PullRequestsMap in an
onUIThreadUnlessRepoChanged bounce (the "no github remotes" and "no base
remote" early-returns clear them the same way). rebuildPullRequestsMap
moves into the bounce so the map is built from Model.Branches and
Model.Remotes as they stand on the UI thread — after those scopes'
refreshes have applied their own bounces — rather than from whatever the
worker happened to see.

The remaining worker-side reads of Model.Branches (to pick which upstream
branches to query) are the same not-yet-addressed worker-read race that
applies to the other bounced scopes.

RefreshingPullRequestsMutex is left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 4c9fdc4221 Bounce STATUS view update onto the UI thread
refreshStatus computes the status line on the calling goroutine (as
before) but now writes it to the status view in an
onUIThreadUnlessRepoChanged bounce rather than calling SetViewContent
directly from the worker. RefreshingStatusMutex is left in place for now;
it only guards the compute phase between concurrent callers and comes out
in the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 063bba6b45 Bounce REFLOG model updates onto the UI thread
refreshReflogCommits now does the git fetch on the worker and computes
the new ReflogCommits / FilteredReflogCommits values (still reading the
existing slices for the incremental prepend), then writes them in an
onUIThreadUnlessRepoChanged bounce. The freshly-computed reflog is still
returned for the branches load, so recency sorting is unaffected by the
write now landing on the UI thread (see the previous commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller bd47106d03 Thread reflog commits explicitly into the branches load
BranchLoader.Load reads the reflog commits to sort branches by recency.
Today it reads them straight from Model.ReflogCommits, which works
because in the recency path the reflog refresh writes that field
synchronously just before the branches refresh reads it (same goroutine,
sequential).

An upcoming commit bounces the reflog model write onto the UI thread, at
which point Model.ReflogCommits wouldn't be updated yet when branches
runs — branches would sort by the previous refresh's reflog. To decouple
the branches load from *when* that write lands, pass the reflog commits
to refreshBranches explicitly: refreshReflogCommits now returns the
commits it loaded, and the recency path hands them straight to
refreshBranches. The non-recency path (branches and reflog run
concurrently, as before) keeps passing Model.ReflogCommits.

Pure refactor: behavior is identical, since the value passed is exactly
what Load read from the model before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller db5eb6fd39 Bounce REMOTES model updates onto the UI thread
refreshRemotes now loads the remotes on the worker and writes
Model.Remotes, rebuilds the pull-requests map, and updates the selected
remote's RemoteBranches inside an onUIThreadUnlessRepoChanged bounce.

RemotesController.addAndCheckoutRemote read Model.Remotes right after its
SYNC REMOTES refresh to select the newly-added remote; since that write
now bounces, the selection (and the follow-up fetch) move into Then so
they run against the post-refresh model rather than the stale one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 0f85c2b2b4 Bounce SUB_COMMITS model updates onto the UI thread
refreshSubCommitsWithLimit now loads the sub-commits on the worker and
writes Model.SubCommits (and folds their authors into Model.Authors via
RefreshAuthors) inside an onUIThreadUnlessRepoChanged bounce.

SubCommitsMutex and AuthorsMutex are left in place: the former is shared
with setSubCommits, the latter with the commits refresh's RefreshAuthors
call, so both come out only once those other writers are on the UI thread
too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 559b4bf298 Bounce REBASE_COMMITS model updates onto the UI thread
refreshRebaseCommits now computes the merged rebasing commits and working
tree state on the worker and writes Model.Commits /
WorkingTreeStateAtLastCommitRefresh in an onUIThreadUnlessRepoChanged
bounce. LocalCommitsMutex is left in place for now; it's shared with the
commits and branches refreshes and comes out once they're all bounced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller d6f6d0ceba Bounce WORKTREES model updates onto the UI thread
refreshWorktrees now writes Model.Worktrees in an
onUIThreadUnlessRepoChanged bounce. loadWorktrees becomes a pure loader
that returns the worktrees instead of writing them, since it's shared
with refreshBranches; refreshWorktrees bounces the result, and the
branches call site writes it directly for now (that write moves into
refreshBranches's own bounce when that scope is migrated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller ff7ecf2d2a Bounce STASH model updates onto the UI thread
refreshStashEntries now loads the stash entries on the worker and writes
Model.StashEntries in an onUIThreadUnlessRepoChanged bounce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 21f1dc3366 Bounce TAGS model updates onto the UI thread
refreshTags now captures the repo generation, loads the tags on the
worker, and writes Model.Tags in an onUIThreadUnlessRepoChanged bounce
rather than directly from the worker goroutine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller b203ec57ac Bounce COMMIT_FILES model updates onto the UI thread
refreshCommitFilesContext now enqueues the Model.CommitFiles write and
CommitFileTreeViewModel.SetTree() call via OnUIThread, instead of running
them directly on the worker goroutine that drives async refreshes. This is
what makes moving SwitchToDiffFilesController's post-refresh work into Then
(previous commit) actually necessary, rather than just future-proofing.

Same repo-switch hazard as the FILES bounce, closed the same way: it
captures the repo generation before the git work and bounces through
onUIThreadUnlessRepoChanged, so the write is dropped if the user switched
repos while it was in flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 6203a4e411 Move post-COMMIT_FILES-refresh work into Then
SwitchToDiffFilesController.enter calls SelectPath and Context.Push
right after a (SYNC, by default) COMMIT_FILES refresh. This works today
because the model write currently happens synchronously in the worker
before Refresh's wg.Wait() returns, but an upcoming commit will bounce
that write onto the UI thread instead, at which point wg.Wait() no
longer guarantees it's been applied, and SelectPath would operate on a
stale tree.

Move both calls into Then ahead of that change, for the same reason as
the earlier FILES-scope commit: Then is already queued via OnUIThread,
so this is behavior-preserving on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 2c139b6ac1 Remove RefreshingFilesMutex/FileTreeViewModel.RWMutex, dead code
FileTreeViewModel.RWMutex is removed along with the
withFileTreeViewModelMutex wrapper in FilesController that RLocked it:
every writer (the bounce closure, previous commit) and every reader (key
handlers, disabled-reason callbacks) now runs on the UI thread, so the
mutex is redundant.

RefreshingFilesMutex is removed entirely, including its last use in
repos_helper's DispatchSwitchTo. That use predates the bounce and was
never about FilesController's optimistic-rendering concern; it serialized
a repo switch's onNewRepo() against an in-flight FILES refresh for the
repo being switched away from, so that a slow refresh from the old repo
couldn't write into the freshly-reset model for the new one. Bouncing the
write already broke that guarantee on its own terms — the mutex's critical
section never covered the bounced closure's actual execution, only the
(now-removed) code that enqueued it — so by this point it was only still
locked here without protecting anything real; the previous commit's
repo-generation guard is what now actually closes that race, making this
lock fully redundant rather than just relocated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller be897ce55e Bounce FILES model updates onto the UI thread
refreshStateFiles now does its git work on the worker and enqueues a
single OnUIThread closure that writes Model.Submodules, Model.Files, and
the FileTreeViewModel state together, instead of writing them directly
from the worker goroutine. refreshStateSubmoduleConfigs becomes a pure
getter (returns the configs; no model write) so the result can be
threaded into that same bounce.

The STAGING handler wraps RefreshStagingPanel in OnUIThread after
fileWg.Wait() so it sees the post-bounce file model rather than the stale
pre-refresh one — without this it would race the files bounce queued just
above it.

Bouncing the write opens a hazard the old synchronous write didn't have:
if the user switches repos while this refresh is in flight, the queued
closure would fire after resetState has replaced the model with a fresh
one for the new repo, silently overwriting it with the previous repo's
files. Guard against this with a repo generation: resetState bumps a
counter on every switch, refreshStateFiles captures it before its git
work, and onUIThreadUnlessRepoChanged drops the bounce if the generation
has moved on. This one helper is the general mechanism the remaining
scopes' bounces will use too; the same guard covers the rebase-continue
prompt, which reads Model.Files right after.

A generation counter, not a comparison of the *Model pointer: switching
away from and back to a repo reuses that repo's cached state (the same
Model pointer), which a pointer comparison would wrongly accept even
though the in-flight data is stale.

PromptToContinueRebase's Then callback (previous commit) now gets an
explanatory comment, since this is the commit that makes it necessary.

The explicit locking around these writes (RefreshingFilesMutex in
refreshFilesAndSubmodules, FileTreeViewModel.RWMutex around the write in
refreshStateFiles) is left in place for now even though it's becoming
redundant, to keep this commit focused on the bounce itself; it's removed
next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller ea83f50dc3 Move post-FILES-refresh model reads into Then
PromptToContinueRebase and WithEnsureCommittableFiles both read
Model.Files right after a SYNC FILES refresh. This works today because
the model write currently happens synchronously in the worker before
Refresh's wg.Wait() returns, but an upcoming commit will bounce that
write onto the UI thread instead, at which point wg.Wait() no longer
guarantees it's been applied.

Move both reads into Then ahead of that change. Then is already queued
via OnUIThread (previous commit), so this is a behavior-preserving
refactor on its own: the model is fully written by the time Then runs
either way, whether that write is still synchronous or gets bounced
later.

As part of restructuring WithEnsureCommittableFiles, prepareFilesForCommit
and syncRefresh are inlined into their single call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 717448f105 Make RefreshOptions.Then a func() error, queue it via OnUIThread
This is preparation for upcoming commits that will bounce refresh-scope
model updates (e.g. Model.Files) onto the UI thread by enqueuing the
write via OnUIThread instead of applying it directly on the worker
goroutine. Once that lands, a Then callback that reads the model must
run after that queued write has been processed, not synchronously at
wg.Wait() time — at that point the workers have returned, but a bounce
they queued may not have been processed yet.

Queuing Then via OnUIThread here, ahead of that change, guarantees the
right ordering once it lands: a bounce queued earlier in the same
refresh is already sitting in the channel by the time wg.Wait()
returns, so Then enqueued after it will always be processed after, and
see the post-refresh model.

The signature change to func() error lets Then propagate errors
through gocui's normal error handler (the same path key-handler errors
take).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller b54d4c369b Remove unused IsRefreshingFiles state
GetIsRefreshingFiles() is never called anywhere in the codebase, so the
flag serves no purpose. Remove it from Gui, StateAccessor, and
IStateAccessor, and drop the two SetIsRefreshingFiles calls in
refreshFilesAndSubmodules that maintained it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 5350b6c37b Remove unused CommitFileTreeViewModel.RWMutex 2026-07-07 18:09:33 +02:00
Stefan Haller dd1576138a AGENTS.md additions 2026-07-07 18:09:33 +02:00
Stefan Haller fa9429f8c7
Fix stuck inline status when pushing/fetching (#5768)
Operations that show an inline status next to the item they operate on
("Pushing", "Fast-forwarding", "Fetching", …) would sometimes fail to
remove this inline status when done.

Fixes #5534.
2026-07-07 18:08:19 +02:00
Stefan Haller 78dd678ce5 Drop the now-redundant manual re-render in tag push
Pushing a tag triggers no refresh, so it used to redraw the tags view
by hand to remove the "Pushing" inline status. WithInlineStatus now
always re-renders after clearing the operation, so this is redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:16 +02:00
Stefan Haller 775ebc9e44 Re-render to clear an inline status when its operation finishes
Operations that show an inline status ("Pushing", "Fast-forwarding",
"Fetching", …) removed it by relying on the async refresh they trigger
to redraw the view after the item operation had been cleared. That
ordering was never guaranteed: the item operation is cleared on the
worker once the operation's function returns, while the refresh redraws
the item from the UI thread whenever its (asynchronous) git work
happens to finish. If the refresh redrew before the clear, the status
was left on screen with no later redraw to remove it, so the branch (or
tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though
the operation had completed. This is timing-dependent, which is why it
surfaced as rare, hard-to-reproduce reports and as flaky CI failures.

Fix it by re-rendering in stop() right after clearing the operation,
and by making these refreshes synchronous rather than async. Because a
synchronous refresh has already updated the model and queued its own
redraw by the time stop() runs, and UI-thread callbacks run in order,
the redraw we queue here runs last and draws the up-to-date model with
the status removed. An async refresh couldn't give that guarantee: its
model update might not have landed yet, so the redraw could briefly
flash the pre-operation status.

Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit,
so that helper becomes synchronous too; its only other caller,
RegularMerge, thereby also refreshes synchronously, which is fine: a
synchronous on-worker refresh is what we want anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:16 +02:00
Stefan Haller 3537f855b0
Show a spinner for more long-running operations (#5765)
Some checks are pending
Continuous Integration / Integration Tests - git ${{matrix.git-version}} (2.32.0) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}} (2.38.2) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}} (2.44.0) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}} (latest) (push) Waiting to run
Continuous Integration / build (push) Waiting to run
Continuous Integration / check-codebase (push) Waiting to run
Continuous Integration / lint (push) Waiting to run
Continuous Integration / upload-coverage (push) Blocked by required conditions
Continuous Integration / check-for-fixups (push) Waiting to run
Codespell / Check for spelling errors (push) Waiting to run
Generate Sponsors README / deploy (push) Waiting to run
Some operations used to freeze lazygit while they ran, with no sign that
anything was happening — the UI would just lock up until they finished.

This affected:

- Merging a branch (including squash merges)
- Rebasing a branch interactively onto another ref
- Setting a commit to "edit"
- Resetting to a commit, branch, or tag
- Continuing, skipping, or aborting a merge or rebase

Many of these were usually fast under normal conditions (e.g. a hard
reset when the head doesn't change, or a "rebase --continue" when
there's only a handful of commits left), but in some cases they could
take long (e.g. a hard reset to some distant commit where lots of files
changed). Now each of these shows a spinner while it works and keeps the
UI responsive, matching how similar operations already behave.
2026-07-06 12:36:47 +02:00
Stefan Haller 6681ba7eda Show a waiting status while resetting to a ref
Resetting to a commit/branch/tag from the reset menu ran inline on the UI
thread with no spinner; a hard reset to a distant commit can take a while
and blocks the UI meanwhile. Run it on a worker with a waiting status. The
undo/redo callers of ResetToRef already wrap it this way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:56:07 +02:00
Stefan Haller ccf49c8112 Show a waiting status when editing a commit
Setting a single commit to "edit" ran the interactive rebase inline on the
UI thread with no spinner, while its sibling startInteractiveRebaseWithEdit
(used when editing multiple commits or quick-starting a rebase) already runs
on a worker with a waiting status. Make the direct path match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:55:28 +02:00
Stefan Haller 2bae29c6fd Show a waiting status when starting an interactive rebase onto a ref
The interactive-rebase item in the rebase-onto-ref menu ran inline on the UI
thread with no spinner, unlike its two siblings in the same menu (simple
rebase and rebase onto base branch), which already run on a worker with a
waiting status. Make it consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:55:12 +02:00