A refresh from the UI thread returns immediately and applies its model
and view updates as queued UI-thread callbacks. A key pressed before
those have run is handled against the stale, pre-refresh state. For most
keys that's harmless, but some handlers turn that state into git
commands: pressing space twice in quick succession in the staging panel
builds the second patch from the already-applied diff and fails with
'patch does not apply', because the refresh after the first press is
what moves the selection to the next stageable hunk.
Notably, this is not just a regression of the recent change that made
UI-thread refreshes non-blocking; the window was merely much narrower
before. A blocking refresh parked the UI thread while the scopes'
bounces were queued, and the event loop drains pending keyboard input
with priority over queued user events, so a key pressed during the
blocked window still beat the queued state updates. The guarantee that
the next keypress sees post-refresh state had already ended when the
scopes' state updates moved from worker-side mutex-guarded writes to
UI-thread bounces.
Fix it with the input-blocking mechanism we already use for commit
surgery, exposed as a new RefreshBlockingInput entry point: it begins
blocking events synchronously in the calling handler, and ends the
block from a callback that the finishing step queues behind the
refresh's own updates. Keys pressed while the refresh is in flight are
buffered and replayed, in order, against the fully refreshed state;
since a replayed key's handler re-enters this same path, a burst of
keypresses applies sequentially, each one seeing the previous one's
refresh. Unlike the old blocking refreshes, this doesn't freeze the UI
thread: rendering, spinners, resizing, and mouse scrolling keep working
while input is withheld.
Blocking input is opt-in per call site rather than the default for all
UI-thread refreshes, because most refreshes (the focus-in and startup
refreshes, say) don't produce state that the next keypress depends on,
and blocking on them would delay typing for no reason. It should also be
limited to quick, narrow-scoped refreshes: a full refresh, or any scope
that pulls in COMMITS, can take very long in large repos and should
usually not hold up input.
The staging panel's stage/discard/edit-hunk refreshes use it now.
Pressing space twice in quick succession in the staging panel is supposed
to stage two hunks: the refresh triggered by the first press rebuilds the
panel's diff and moves the selection to the next stageable hunk, and the
second press stages that.
Since we made UI-thread refreshes non-blocking, the second press is
handled as soon as it arrives, while that refresh is still in flight. It
then reads the stale pre-refresh diff, builds the first hunk's patch
again, and git apply fails with 'patch does not apply' because those
lines are already in the index.
The test documents this currently broken behavior; the fix comes next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <hash>" 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.
When loading the files of a commit we passed --no-renames, so a rename
showed up as a separate delete and add rather than a single R entry.
That made it impossible to work with a rename that also modifies the
file: the modifications were spread across a full deletion and a full
addition instead of appearing as the handful of lines that actually
changed. The staging view already shows renames and lets you stage
their hunks, so there was no good reason for the patch builder to
differ; the flag was only there because the commit-file parser couldn't
cope with the rename record format.
Switch the commit-file loader and the per-file diff to --find-renames,
teach the parser about the rename record (a status followed by two
paths), and carry the previous path through the patch builder so the
diff for a rename is loaded with both paths, which is what makes git
emit the rename in the first place.
A whole-file selection keeps the rename in the header, so the rename
moves or is discarded together with the file's contents. A partial
selection instead strips the rename metadata and points the header at
the new path, so applying the patch only changes the contents and
leaves the rename in place; the blob index line is kept so that a 3-way
apply can still fall back to a blob merge.
Discarding a renamed file from a commit now discards both the new and
the old path, so the new file is removed and the old one is restored.
Changing the rename similarity threshold refreshes the commit files
panel too, not just the files panel, so that a rename can turn into a
delete and add or back. It is disabled while building a patch, however,
because the patch builder caches each file's diff by path and would
desync if a rename changed into a delete and add underneath it.
Finally, copying a file's diff from the commit files panel now passes
both paths for a rename, so the copied diff shows the rename instead of
a new-file add.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pressing `d` on a worktree only ever removed the worktree, leaving its branch
behind even though deleting it too is often what you want. Turn the confirmation
into a menu: "Remove worktree", "Remove worktree and delete branch", and "Remove
worktree and delete local and remote branch". The branch-deleting items come
after the plain removal (they do more harm if picked by accident); both are
greyed out for a detached-HEAD worktree, and the local-and-remote one is also
greyed when the branch has no upstream. The plain menu pick is the confirmation,
so the standalone "remove worktree?" prompt is gone (and its now-dead
translation string with it); the dirty-worktree force prompt and the
unmerged-branch warning still appear when relevant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picking "Delete local and remote branch" for a single branch that's checked
out in another worktree used to fail with "Some of the selected branches are
checked out by other worktrees. Select them one by one to delete them." That
message only makes sense for a multi-selection; for a single branch there's no
reason we can't remove the worktree and delete both the local and remote branch
in one go. Route that case through the same worktree menu as the local-only
delete, with labels that spell out that the remote goes too. The multi-select
error stays for actual multi-selections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When you delete a local branch that's checked out in another worktree, the
menu offered to remove or detach the worktree but then stopped there, leaving
the branch you asked to delete still around. Now both actions delete the branch
afterwards, and the labels say so ("Remove worktree and delete branch" /
"Detach worktree and delete branch") to avoid surprises.
Also drop the "Switch to worktree" item: switching abandons the delete the user
asked for, and it's already reachable by checking out the branch or via the
worktrees panel. And drop the now-redundant "remove worktree?" confirmation:
the explicit menu pick is the confirmation (the dirty-worktree force prompt and
the unmerged-branch warning still appear when relevant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lazygit runs git directly rather than through a shell, so a literal "~"
reaches `git worktree add` unexpanded and git creates a directory named
"~" instead of using the home directory.
Expand the tilde ourselves, both for paths typed into the "Other"
location prompt and for the worktree.defaultPath config value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The command was renamed from "View worktree options" to "New worktree",
but its keybinding config key was still 'worktrees.viewWorktreeOptions'.
That name no longer matches the command, and the 'worktrees' section made
little sense: it held a single binding that isn't even used in the
worktrees panel (that panel uses universal.new), only in the branches,
remotes, tags, commits, and stash panels. Other keybinding sections are
named after the panel they're local to; this one wasn't local to any.
Move it to universal.newWorktree, which describes the action and drops the
spurious section, and migrate existing configs automatically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old 'n' flow opened a "normal vs detached" menu (the same meaningless
gate the 'w' flow used to have), then asked for a base ref, a path typed from
scratch, and a branch name in three separate prompts.
Replace it with a single picker prompt titled "New worktree for branch",
suggesting local branches not already checked out anywhere, plus remote
branches that don't yet have a local branch of the same name. The entered
value is classified on confirm: an existing local branch checks out into a
new worktree, a remote branch creates a new local tracking branch, and
anything else creates a new branch off the current ref. All three then feed
the same location menu the 'w' flow uses, so paths are chosen from candidates
rather than typed blind. Picking a remote or new branch needs no separate
name prompt — the picker value already is the name. Checked-out branches are
filtered from the suggestions, and a verbatim type-in of one is rejected with
an error.
createWorktree now takes the context to switch focus to once the worktree is
created, so 'n' lands back in the worktrees panel while 'w' still lands in
the branches panel.
This deletes the old NewWorktree / NewWorktreeCheckout core and the now-
orphaned i18n (CreateWorktreeFrom, CreateWorktreeFromDetached, NewWorktreeBase,
NewBranchNameLeaveBlank), completing the migration started for 'w'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old flow forced an up-front "normal vs detached" menu (meaningless for
commits, tags and stashes), then asked the user to type a worktree path from
scratch — easy to get wrong, and ambiguous about what relative paths resolve
against. It also offered the same two actions everywhere regardless of what
was selected.
Replace it with per-context "Worktree" menus whose items imply the intent
(new branch + worktree, worktree for an existing branch, detached worktree),
each feeding a shared name -> location -> create pipeline. The location menu
offers candidate parent directories as absolute paths instead of a blank
field, and "Worktree for a branch" is disabled (with a reason) when that
branch is already checked out somewhere, rather than failing after the fact.
Each ref/commit panel binds 'w' in its own controller and calls the matching
typed entry point on the worktree helper, so which menu opens is decided
statically by the call site rather than by dispatching on a ref's dynamic
type. The three commit panels share one menu through BasicCommitsController;
there is no longer a shared worktree-options controller.
The worktrees-panel 'n' flow and its old core (NewWorktree /
NewWorktreeCheckout) are left untouched here so nothing is written and then
rewritten; they migrate, and the dead i18n strings get removed, in a
follow-up commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prompt offering to continue a rebase/merge is opened from a refresh
and then left to sit until the user acts on it. But the operation can
change out from under it: a coding agent (or the user in another
terminal) might continue or abort it, or advance it to a commit with new
conflicts. The prompt then becomes stale — pressing continue fails with
"no rebase in progress" or acts on the wrong state.
Track whether the prompt is showing, and on each refresh dismiss it if
the operation is no longer in the "resolved, ready to continue" state
that the prompt is offering to act on. This runs on the same refreshes
that would open it (including the background poll and the refresh on
window focus), so the prompt disappears on its own shortly after the
operation moves on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When conflicts of an in-progress rebase/merge/cherry-pick/revert are
resolved, lazygit pops up a prompt offering to continue it. This is
helpful when you started the operation in lazygit and resolved the
conflicts in your editor. But it's confusing when the operation was
started outside lazygit — e.g. by a coding agent in another terminal
that resolves the conflicts but hasn't continued yet because it's still
running tests or fixing the build. lazygit would then prompt unbidden.
Track whether the in-progress operation was started from within lazygit,
and only show the prompt in that case. We record this right after running
a merge/rebase step (in CheckMergeOrRebaseWithRefreshOptions, the
subprocess branch of genericMergeCommand, and the custom-command
conflict path), and clear it whenever a refresh observes that no
operation is in progress — which also handles an operation that was
finished or aborted externally.
The conflict-resolution tests start their operation by running git
directly (not through lazygit's UI), so they call the new test helper
Common.PretendMergeOrRebaseStartedInLazygit to have lazygit treat the
operation as its own and still get the prompt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`b` on a merge conflict is meant to keep both sides. With the diff3
conflict style git additionally renders the common ancestor between the
two sides, and the old ALL selection kept everything between the
outermost markers, dragging that ancestor into the resolved file.
Rename the selection from ALL to BOTH and restrict it to the top and
bottom hunks so the common base is dropped. Without the diff3 style
there is no ancestor section, so the behaviour there is unchanged.
The user-facing keybinding config was already named pickBothHunks; only
the internal enum, handler, translation and log string still said "all".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pressing `b` on a conflict is meant to pick both sides. With the diff3
conflict style, git also renders the common ancestor between the two
sides, and `b` currently keeps that ancestor section too, which is
wrong: the common base is neither side of the merge and must not end up
in the resolved file.
This test captures the current (buggy) behaviour so the follow-up fix
has a clear before/after.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a conflicted submodule is selected, the main view shows the commits
each side added relative to their common ancestor as two indented logs,
labelled current and incoming, so it's clear which commit each side would
resolve to.
The logs aren't truncated (the view scrolls). If a side added no commits
of its own (e.g. it was rewound to an ancestor of the other), its head
commit is shown instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When both sides of a merge moved a submodule's gitlink, git reports it as "UU".
Pressing space used to fall into the submodule no-op guard and pop the confusing
"Nothing to stage..." error, and enter just entered the submodule, which does
nothing to resolve the superproject conflict.
Treat a conflicted submodule like the other non-textual conflicts: both space
and enter now open a picker offering the two candidate commits, "current" and
"incoming", each labelled with its summary. `git checkout --ours/--theirs` is a
no-op on gitlinks, so we resolve by checking the submodule out at the chosen
commit and staging it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For a non-textual conflict (e.g. DD/AU/UA/UD/DU), pressing space used to run the
normal stage path, which did something unclear: `git add` happens to resolve the
conflict by keeping the file, but that's neither obvious nor symmetric. Route a
single such file to the same Keep/Delete picker that enter opens, so space and
enter agree.
For a range selection that includes one of these conflicts, staging makes no
sense, so disable it with a toast that points the user at resolving them one at
a time. (Entering a range was already disabled with the standard toast.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exercises the path the live reload relies on: a per-repo lazygit.yml
sets a different side panel layout, and switching between repos
re-applies each one's own layout (the new-repo path for the cloned repo,
the cached-repo path on switching back).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the config file changes and lazygit regains focus it reloads the
config, but the side panel window assignments, default views, tab
strips, and z-order were only ever set up on repo entry, so a changed
sidePanels wouldn't take effect until restart. Re-apply it from the
reload path: reassign windows and default views and restore each panel's
default tab.
The focused panel needs care: resetting it to its default tab would
leave the focused tab hidden behind that default tab, so the panel looks
unfocused even though its tab is selected. Re-focus the current context
so its tab stays shown and highlighted; only when the new config hides
the focused panel entirely do we move focus to the default side panel.
Tab strips are already refreshed via configureViewProperties.
Cover the three things gui.sidePanels enables: reordering the panels
(swapping branches and commits, checked via their jump keys), hiding a
panel (omitting stash, checked by cycling past the last panel and wrapping
to the first), and promoting a tab to its own panel (worktrees becomes a
top-level panel reachable by a jump key, and the files panel's remaining
tabs cycle straight to submodules).
The tests drive focus with explicit jump keys rather than ViewDriver.Focus,
which assumes the default panel layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the recently added external change detection, it happens more often
now that we refresh the commits list because an agent made a commit in
the background. In this case, if we keep the selection index the same,
it now points at a different commit, making the main view show a
different commit too, which is confusing and annoying. To fix this,
track the selected commit and range anchor by hash before reloading,
then restore those rows if both hashes still exist. This also allows us
to get rid of some bespoke code that did this for the specific cases of
reverting a commit or cherry-picking commits, because those are now
handled by the generic mechanism.
This guards against regressions from the changes that follow. We're
about to add a mechanism that keeps the selection anchored by commit
hash, but we need to make sure that it doesn't take effect here; after a
merge we want to select the newly added merge commit. In the current
state of the code this happens to work because we keep the selection
index the same, which happened to be 0 here; later we will change this
to explicitly select the head commit after the merge.
With more than a couple of pagers, having to cycle forward through all
of them to reach the previous one (or to back out of an accidental press
of `|`) is tedious. Add a second binding that cycles backward.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A submodule that only has dirty or untracked content (no new commit) can't
be staged from the parent repo, but it still shows up as having unstaged
changes. Pressing stage on it therefore briefly flashed as staged and then
reverted, without explaining why nothing was staged.
Detect this case (via `git submodule status`, where a '+' prefix marks a
stageable commit change) in the shared stage/unstage decision: if the only
thing that looks stageable is such a submodule, don't try to stage it.
Instead unstage if there's anything staged to unstage, so the toggle stays
symmetric; otherwise show an error explaining that there's nothing to stage.
Because the decision is shared, this covers both the stage (space) and
stage-all (a) keybindings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Before the staging decision was unified, the stage (space) and stage-all
(a) keybindings each made their own decision, so a fix to one wouldn't
reach the other. Extend the test to drive the submodule through stage-all
as well, guarding against that asymmetry coming back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stage/unstage toggle decides what to do based on whether a node has
unstaged changes: if it does, it stages; otherwise it unstages. For a
submodule this breaks down, because dirty or untracked content inside the
submodule always reports as an unstaged change in the parent repo but can
never be staged from there. Once such a submodule's commit pointer is
staged it sits at "MM", and every subsequent press keeps trying to stage
the unstageable dirty content, so it can never be unstaged.
Treat a submodule's unstaged change as stageable only when its commit
isn't already staged, so that a staged submodule unstages on the next
press regardless of leftover dirty content. Because the decision is now
shared by press and stage-all, this fixes both at once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a submodule has both a new commit (which the parent repo can stage)
and dirty working-tree content (which it can't), staging it lands on a
"MM" status. Pressing space again should unstage it, but instead it tries
to stage the dirty content over and over, so you can never get back to an
unstaged state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a user switches into a repo whose .envrc hasn't been approved with
`direnv allow`, the previous behavior was to drop a "blocked" error
popup and leave the user to fix it externally. That meant opening a
terminal, running `direnv allow`, and then either restarting lazygit or
switching repos and back to refresh the env — easy to get wrong, easy
to forget.
When `direnv export json` exits non-zero, follow up with `direnv status
--json` to ask direnv whether the current directory has a not-yet-
allowed .envrc, and if so, get its path. Then show a confirmation popup
with the .envrc contents inline so the user can read what they're
approving. Confirming runs `direnv allow <path>` and re-runs the load
so the new env reaches subprocesses immediately; cancelling leaves the
env unloaded (the same state as before this commit when direnv refused
to load the .envrc).
Using `direnv status --json` instead of parsing the "is blocked"
stderr line means we rely on direnv's structured output rather than
its human-readable error format, which is more stable across versions
and avoids assumptions about output formatting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user opens a repo from the recent-repos menu or jumps between
worktrees inside lazygit, only the env vars present at process startup
reach subprocesses. That breaks pre-commit hooks and other tools whose
dependencies are pulled in by a per-repo .envrc — users were left with
read-only operations because the env their shell would normally load via
direnv never made it into lazygit's git invocations.
Shell out to `direnv export json` after each chdir and apply the JSON
delta via os.Setenv/Unsetenv. direnv tracks the previous load in its own
DIRENV_DIFF env var, so the delta also unloads vars from the old repo
when entering one without a matching .envrc. If direnv isn't on PATH the
call is a no-op, so users who don't use direnv pay nothing and users who
do need no config to opt in. Any stderr direnv emits (loading messages,
"blocked .envrc" errors, etc.) goes to the command log.
The integration test puts a fake direnv on PATH and asserts that a value
it exports reaches a custom command after switching repos. Wiring this
up needed runner.go to support `{{actualPath}}` placeholders in
ExtraEnvVars, mirroring the existing support for ExtraCmdArgs, so the
test can prepend a fixture-relative directory to PATH.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a keybinding that we want to display in the options bar was set to <disabled>
by the user, in pre-0.62 versions we would still display the command, but with
no keybinding. This was arguably not very useful before, but now it actually
crashes because we would now try to display the first key of the slice of
configured keys (crash introduced in 3d18ee8f91). Fix the crash by not showing
those commands at all.
CustomCommand.Key and CustomCommandMenuOption.Key are user-configured
keybindings just like the built-in ones. Converting them to the Keybinding type
lets a user assign multiple keys to the same custom command, e.g. `key: [a, b]`,
the same way they would for any other keybinding.
The validator iterates over the elements rather than checking a single string,
the binding registration goes through GetValidatedKeyBindingKeys to register
every alternate, and the existing error messages use .String() so a multi-key
binding renders sensibly.
CustomCommandPrompt.Key (a form field name, not a keybinding) stays a plain
string.
JumpToBlock is special: each of its 5 elements is the binding for one side
window (status / files / branches / commits / stash), not an alternate for a
single command. Change the field from []string to []Keybinding so each window
slot can have alternates of its own.
The schema becomes "an array of 5 keybindings, each itself a string or array of
strings", which falls out cleanly from how the Keybinding type inlines into the
generated schema. Existing configs (a flat array of 5 strings) keep validating
because each element is unmarshalled through Keybinding's scalar-or-sequence
decoder.
Until now every keybinding config field was a plain string. That meant a user
couldn't ask for two keys to invoke a command — the config silently accepted
only one form.
Convert every string-typed field across all 13 KeybindingXxxConfig structs to
Keybinding so the union type extends to every command. Defaults wrap their
single-key value in Keybinding{...} so the generated Config.md still renders one
scalar key per binding.
The alt fields keep their separate Binding registrations for now: this commit
does not yet introduce the merge mechanism that folds them into the main field —
that comes in a follow-up. Consumers previously calling opts.GetKeys on a string
field now call opts.GetKeys on the Keybinding, or take .String() / Keys[0] where
a single value is needed.
Adds a Keybinding.String helper for rendering, schema-generator work that
inlines the Keybinding union into each consuming property, and a unit test
covering the user-facing scalar/sequence YAML forms for quit.
For legacy reasons, OptionMenu was set to `<disabled>`, and OptionMenuAlt1 to
`?`. This doesn't make a lot of sense any more; get rid of OptionMenuAlt1 and
bind OptionMenu to `?` by default. This is a breaking change for users who
rebound OptionMenuAlt1 in their config, but it doesn't strike me as very likely,
and it's easy enough to fix.
AutoForwardBranches relies on the worktree model to skip any branch
that's currently checked out in another worktree (so we don't update
its ref behind the worktree's back). The post-fetch refresh wasn't
including the worktrees scope, so any external change to the worktree
list between lazygit's startup and the fetch — a `git worktree add`,
a `git checkout` in a linked worktree, a branch rename — left the
in-memory model stale and the skip check returned false negatives.
Add WORKTREES to the post-fetch refresh scope when auto-forwarding is
enabled. We gate on the config so users with auto-forward disabled
don't pay for an extra `git worktree list` plus per-worktree rev-parse
on every fetch tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the linked worktree's branch is changed externally — by another
shell, by another tool, or by git running outside lazygit — lazygit's
worktrees model goes stale. The next post-fetch auto-forward then
doesn't realise the branch is now checked out elsewhere, and advances
its ref behind the worktree's back. The worktree's HEAD then resolves
to a commit its index/working tree haven't been updated to, and the
user sees that diff as the inverse of what was fetched — files
appearing as pending changes that they didn't make.
The test sets up a linked worktree initially on a side branch, then
externally checks out master in it before pressing fetch. Two
EXPECTED/ACTUAL pairs capture the symptoms: the branches view shows
master as `✓` rather than `↓1`, and switching to the linked worktree
shows master's would-be incoming file as a pending deletion against
HEAD.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a successful paste DidPaste is true, hiding the "X commits copied"
indicator but leaving the buffer populated. From the user's perspective
this looks like a clean slate, so a new shift+C should start fresh. It is
important to reset DidPaste first, before populating the buffer with the new
commits, because otherwise each loop iteration would overwrite the previous one
since Add() rebuilds the set via SelectedHashSet() which returns empty while
DidPaste is set.
After a successful paste, the "X commits copied" indicator hides and
DidPaste is set, but the buffer is not cleared. If the user then
range-selects multiple commits and presses shift+C, every Add() call
rebuilds the set via SelectedHashSet(), which returns empty while
DidPaste is true; each iteration of the copy loop therefore overwrites
the previous one and only the last commit in the range survives.
The commit graph used '⏣' (U+23E3 BENZENE RING WITH CIRCLE) for merge
commits and '◯' (U+25EF LARGE CIRCLE) for regular commits. Both have
very poor coverage in popular monospace fonts:
- '⏣' lives in the Misc Technical block and is essentially absent from
every common monospace font (Source Code Pro, JetBrains Mono, Fira
Code, Cascadia Code, Hack, Iosevka, Menlo, Consolas, Monaco, IBM
Plex Mono, Ubuntu Mono, Noto Sans Mono, Inconsolata). It is always
drawn from a system fallback font.
- '◯' is the late-addition LARGE CIRCLE codepoint. It is present in
some fonts (Cascadia, Fira Code, Hack, Iosevka, Menlo, Noto Sans
Mono) but missing from many others, including Source Code Pro and
most Nerd Font derivatives based on it.
This is why the graph renders inconsistently across platforms even
when the same monospace font is configured: each OS picks a different
fallback font (Apple Symbols on macOS, Segoe UI Symbol on Windows,
Noto/DejaVu/Symbola on Linux), and the substituted glyphs differ in
shape, weight, and advance width. '◯' is also East Asian Ambiguous
width, so some terminals render it wider than one cell, exaggerating
the misalignment.
Replace the symbols with codepoints from the foundational 1991
Geometric Shapes block, which has far broader font coverage:
- Merge: '◎' U+25CE BULLSEYE -- concentric circles, the visually
closest cousin to the previous benzene-ring glyph.
- Commit: '○' U+25CB WHITE CIRCLE -- the same hollow-circle silhouette
as before, just a more universally available codepoint.
The new symbols are present in the font directly in significantly
more cases; and when fallback is still required, they are universally
well-drawn (unlike '⏣', which many fallback fonts also lack).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This is useful when cancelling out of the commit panel mid-sentence (after
having typed the space for the next word); when entering the commit message
panel again, the space was gone and you had to type it again. Small thing, but
it just seems better to resume the panel in exactly the state that you left it
in. (Which we actually don't do; we don't remember the cursor position, or which
of the subject/description panels was active. That would be a separate
improvement.)
The save path and the load path used to be asymmetric. On save, the textarea
getters applied strings.TrimSpace, which stripped any leading blank lines, a
trailing newline after the cursor, or indentation on the very first line of the
description — all of which are legitimate user content. On load,
SplitCommitMessageAndDescription did TrimSpace on the description as well, and
the preserved message was routed through that same git-format split because
HandleCommitPress passed it as OpenCommitMessagePanel's InitialMessage. The
result: every round-trip through "escape and reopen" silently mutated the
message.
The fix is to treat our own preservation file as its own format, distinct from
git's canonical "summary\n\nbody" format:
- The textarea getters return raw content. strings.TrimSpace moves to the one
place that still needs it: the empty-summary check in HandleCommitConfirm (git
itself strips trailing whitespace and blank lines, so no pre-trim is needed
before -m).
- SplitPreservedCommitMessage / SetPreservedMessageInView split on the single
"\n" our Join uses, without any trimming — truly lossless.
- SplitCommitMessageAndDescription keeps its git-format behavior but replaces
TrimSpace with TrimPrefix("\n"), so it strips only the blank-line separator
and leaves body indentation intact.
- HandleCommitPress now mirrors HandleWIPCommitPress: it no longer passes the
preserved message as InitialMessage. OpenCommitMessagePanel resolves the
preserved content itself, uses it for display via the preservation-format
setter, and stores it as the initial message so the close-time "did the user
change anything?" check still correctly detects a cleared panel.
- GetInitialMessage no longer trims. With raw getters on both sides of the
comparison, trimming here caused spurious non-matches (e.g. for preserved
content with trailing whitespace). The original motivation — matching a
"WIP: " prefix with trailing space — works unchanged.
- UpdateCommitPanelView becomes dead code and is removed; its one remaining
caller (history cycling, always git-format) goes directly through
SetMessageAndDescriptionInView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Change the tests so that they run in a linked worktree; this uncovers the bug
that copying a file's absolute path uses the main repo path rather than the
worktree's path.
When building multi-step custom command forms, some prompts are only
relevant depending on earlier answers. Without conditional logic,
users must dismiss irrelevant prompts manually.
Prompts now accept a `condition` field with a template expression
evaluated against prior form values. Skipped prompts default to
an empty string.
The template expression is a string pre- and suffixed with double curly
braces - {{}}.
Form keys can be reused, a guard ensures that skipped prompts do not
reset already set form keys with an empty string. This allows the
conditional flow to remind a user to set a key that was left empty
because additional conditions want that key to be set. This removes the
need to have additional if checks in the command that uses the form
keys.
Set the sort order's default from the former foldersFirst to mixed, so this is a
change in behavior. I find this useful because it now matches git's order, so if
you look at the diff of a commit, the TOC at the top has the same order as the
file tree you see when entering the commit.
When you rebase a branch and there are conflicts, lazygit asks you to continue
the rebase when it detects that all conflicts have been resolved. However, it is
common to make additional changes beyond just fixing the conflicts (e.g. to fix
build failures), and when doing that while the "Continue the rebase?" prompt is
showing, lazygit detects that too and asks you if you want to stage those newly
modified files too. This is all well and good (and can be disabled for those who
don't like it); however, lazygit would treat out-of-date submodules as unstaged
changes and would offer to stage those as well, and this is pretty bad and
almost never what you want. Fix this by excluding submodules from that second
check.
Previously it would iterate over all changed files and call git checkout or git
reset for each one, which can take forever if there are hundreds or thousands of
files. Now it batches these into a single command if possible (taking care of
still passing the individual path names to the git call rather than just the
directory, which is necessary for making it work correctly when filtering --
this was actually broken for the "Discard unstaged changes" command, which is
fixed here).
When discarding a directory, we only want to discard those files that are
visible in the current filter view. The tests show that this already works
correctly for discarding all changes, but it doesn't for discarding only
unstaged changes: in this case, untracked files are handled correctly, but
changes to tracked files are discarded without respecting the filter.
This fixes the problem; a consequence of this change is that given the following
scenario:
@@ -1,3 +1,3 @@
1
-2
+2b
3
staging only the line `+2b` will put it *before* the unchanged `2` line, rather
than after it as you might expect (the changed unit tests demonstrate this).
Since this should be a pretty uncommon scenario, I guess it is an ok compromise.
As you can see in the changed tests, while the behavior of what gets staged is
fixed now, it doesn't always correctly select the next line to stage. We'll
address this in the next commit.
This is a pretty special case (see comment in the test for details). It is
working correctly, but I almost broke it during development, so it's good to
cover it with a test.
Given a block of consecutive changed lines, trying to stage only some of them
doesn't work correctly in all cases:
- if the staged lines are the last lines in the block of changes, it already
works
- when staging some changes in the middle of the block, it doesn't work as
desired, but we also don't try to fix this case in this branch, because it's
harder to do, and not as common as the other two
- staging the first lines of the block doesn't work as desired, and we will fix
that in this branch.
When a branch is checked out by another worktree, show the worktree
name in the label, e.g. "(worktree cosmos2)" instead of just
"(worktree)", so you can immediately see which worktree holds it.
This commit implements the ability to cycle backward through different
all branches log visualization modes, complementing the existing forward
cycling functionality. Users can now press 'A' (Shift+a) to cycle in
reverse through the available log graph views, improving navigation
efficiency when they overshoot their desired view.
Changes:
- Added RotateAllBranchesLogIdxBackward() method to BranchCommands for
backward rotation through log command candidates
- Introduced AllBranchesLogGraphReverse keybinding configuration with
default key 'A' (uppercase)
- Implemented switchToOrRotateAllBranchesLogsBackward() handler in
StatusController that mirrors forward cycling logic
- Added English translation for "Show/cycle all branch logs (reverse)"
The implementation uses modulo arithmetic with proper handling of
negative indices to ensure seamless backward cycling through the
available log visualization options.
When .git/info directory does not exist (can happen with bare clones
or manual deletion), the Exclude function failed with 'no such file
or directory'. Added os.MkdirAll to create the directory before
opening the exclude file.
Added integration test exclude_without_info_dir that removes .git/info
before attempting to exclude a file.
The worktrees tab now displays the checked-out branch (or detached HEAD
state) for each worktree, making it much easier to see which worktree
holds which branch.
This is useful when you need to "unlock" a branch that's occupied by
another worktree: you can now clearly see which worktrees are on a
branch vs detached, without having to select each one individually.
Also rename the "(main)" label to "(main worktree)" to avoid confusion
with the common "main" branch name, and move it after the branch column.
Change working tree files and commit files panels to use filtering
(reducing the list) instead of search (highlighting matches). This
matches the behavior of other filterable views.
The text filter matches against the full file path, not just the
filename, which is more useful for navigating large directory trees.
When toggling a directory for a custom patch while a text filter is
active, only the visible filtered files in the directory are affected,
consistent with how staging a directory in the files panel works.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this check, the selection was being reset to 0 whenever
ReApplyFilter was called for the current filter context, even when
the user wasn't actively typing in the search prompt (e.g. when the
model updates in the background). This was causing unexpected cursor
jumps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
I always press 'd' in the patch building view, expecting that I can do
exactly what I can do in the staging view, to find out I need to go
space -> ctrl+p -> d and I think it's time to honour the muscle memory
and support this convenience keybinding.
Only mention resetting the patch when there actually is one. This way users have
to read less text in the normal case, and the added note hopefully stands out
more if there is one. Also, separate the note from the previous text by a blank
line.
"Remove" can be confusing when a deleted file is selected; in this cases it
actually "un-removes" it. "Discard" hopefully makes it clearer that we are
talking about the change to a file, and not the file itself.
Paths added to ignore/exclude files need to be prefixed with a forward
slash to point to a specific file in the directory tree.
Without that prefix a file at root called `file` (added to `.gitignore`
as `file`) would match with `./file` and `./src/file`.
I've optimised for muscle memory backwards compatibility here:
- Outside interactive rebase: press 'f' then instead of a confirmation
panel, a menu appears where you can choose to keep the selected commit's
message
- Inside interactive rebase: press 'f' then press 'c' to see the menu
for keeping the message, where if you press 'c' again it will retain the
current message. so 'fcc' is the chord to press.
We're also now showing the -C flag (which is what enables the behaviour)
against the todo.
I've picked the 'c' keybinding because 'C' was taken and it corresponds
to the flag. Previously that showed a warning about a change in
keybinding for cherry picking but it's been ages since we've made that
change so I'm happy to retire it.
We move the selection down by the number of commits that were reverted (to keep
the same commits selected). However, this only happens after refreshing, which
has rendered the main view with the wrong commit, so we need to render it again
after moving the selection.
There are many other callers of MoveSelection in LocalCommitsController, but all
of them happen before the refresh. Revert is special because it needs to move
the selection after refreshing, e.g. when reverting the only commit of a branch.
This is not really super important because we are very unlikely to assign a key
such as esc or up/down to a menu item. However, users might do this in a custom
commands menu, and in that case it is important that the builtin keys still
work; or they might remap those builtin commands to other keys, in which case
they might conflict with single-letter keys in normal menus.
The test shows two problems: a <down> keybinding is not removed from a menu item
(the 'y' binding is removed though, which is correct), and keybindings such as
'j' and 'H' don't work. We will fix both of these separately in the following
commits.
We want to test the order in which the commits are listed in the error message.
For one of the tests the order is already as we want it, but for the other it's
not (we want them to show up in log order). We'll fix this in the next commit.
We move the code to push the branches context into CheckoutRef, this way it
works consistently no matter where we call it from. Previously, checking out
remote branches or tags would switch to the branches view, but checking out a
commit did not.
Note that it now also takes effect for undoing or redoing a checkout, which may
be a bit questionable; but I still think it makes sense for this, too.
Introduce the 'SelectedSubmodule' struct and allow using it as a
placeholder value.
Add a corresponding test.
Update the documentation to include it among the listed placeholder
values.
This will put whatever git's default merge variant is as the first menu item,
and add a second item which is the opposite (no-ff if the default is ff, and
vice versa).
If users prefer to always have the same option first no matter whether it's
applicable, they can make ff always appear first by setting git's "merge.ff"
config to "true" or "only", or by setting lazygit's "git.merging.args" config to
"--ff" or "--ff-only"; if they want no-ff to appear first, they can do that by
setting git's "merge.ff" config to "false", or by setting lazygit's
"git.merging.args" config to "--no-ff". Which of these they choose depends on
whether they want the config to also apply to other git clients including the
cli, or only to lazygit.
I can't do my usual "add the tests first, with EXPECTED/ACTUAL sections that
document the bug" method here, because the tests would hang without the bug
being fixed.
We need two different tests here: one where a cherry-picked commit simply
becomes empty "by itself", because the change is already present on the
destination branch (this was only a problem with git versions older than 2.45),
and the other where the cherry-pick stops with conflicts, and the user resolves
them such that no changes are left, and then continues the cherry-pick. This
would still fail even with git 2.45 or later. The fix is the same for both cases
though.
The tests show that the selection behavior after skipping an empty cherry-picked
commit is not ideal, but since that's only a minor cosmetic problem we don't
bother fixing it here.
Our logic to decide if a file needs to be checked out from the previous commit
or deleted because it didn't exist in the previous commit didn't work for
submodules. We were using `git cat-file -e` to ask whether the file existed, but
this returns an error for submodules, so we were always deleting those instead
of reverting them back to their previous state.
Switch to using `git ls-tree -- file` instead, which works for both files and
submodules.
It works for submodules too.
Also, pass file name and file content explicitly; the existing tests don't care
about these, but when writing tests that do, it makes them easier to understand.
Replace merge-tool with merge options menu that allows resolving all
conflicts for selected files as ours, theirs, or union, while still
providing access to the merge tool.