When dragging a commit with auto-scrolling so that the original commit
leaves the viewport, dragging back into the view makes the original
commit snap back into view. This is a regression that was introduced by
aebf495dce.
When a rebase (or multi-commit cherry-pick or revert) stops with a
conflict, it is often useful to look at the diff of the "<-- CONFLICT"
commit to double-check that the conflict resolution matches the diff of
the original commit. To make that easier, select that commit
automatically.
This doesn't change anything, we just pin down the selection behavior
around conflicts; we are going to change that behavior, and these tests
will make it obvious how when they change in the next commit.
While a commit drag is in progress, escape now aborts it: the drag
state and the drop indicator are discarded and the mouse capture is
released, so nothing happens when the button is eventually released.
Otherwise escape keeps its normal meaning.
Reuse the drag autoscroller for commit drags. Scrolling stops once the
insertion point reaches the end of the allowed range in the scroll
direction, so during a rebase the view doesn't keep scrolling once the
last insertion position among the todos has been reached.
Pressing the left button on the current selection now starts a drag
that moves the selected commits, both in the normal commits view and
for todos during an interactive rebase. A press anywhere else falls
through to the usual click handling, so dragging from an unselected
line still creates a range selection, and releasing without having
moved collapses the selection to the pressed commit like a plain click
would.
While dragging, the insertion point follows the pointer: rows below
the dragged block insert after the pointed-at commit, rows above it
insert before it, and during a rebase the destination is limited to
the contiguous block of movable todos around the selection. gocui
moves the view cursor along with the pointer, so each drag event moves
it back to keep the original selection highlighted.
The move happens on release. The model may have been refreshed during
the drag, so the dragged commits are located again by their identity
(hash, subject, todo action); if they no longer form a unique
contiguous block, the drop is ignored rather than guessing.
I'm not a skilled UI designer, so I suspect there may be even better
options, but it's definitely already better than the raw ASCII "---" we
had before.
Put the line only at the beginning because it looks bad if the line
after the label is misaligned when labels don't have the same width
(e.g. "Remote" vs. "Local" in the divergence view).
Moving a todo rewrites the todo file and advances the selection
synchronously, but the commits model is only rebuilt by the refresh. A
second press arriving before that grabs the swapped-with todo from the
stale model at the advanced index and moves it back, so holding the key
to move a todo several slots misbehaved. Use RefreshBlockingInput so the
second press is buffered and replayed once the moved todo list is in
place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moving a todo up or down rewrites the todo file and advances the
selection synchronously, but the commits model is only rebuilt by the
refresh, which finishes in the background. A second keypress arriving
before that reads the pre-move model at the advanced selection index —
that's the todo the first move swapped with, so the second press moves
that one back instead of moving the selected todo further. Two rapid
presses (e.g. from holding the key down) thus amount to a net no-op.
The two presses also spawn two racing refreshes whose model updates can
land in either order, so the todo list can even end up disagreeing with
the todo file. That's why the test continues the rebase and asserts the
resulting commit order instead of the displayed list: the rebase replays
the file, which is deterministic.
The test documents this currently broken behavior; the fix comes next.
Co-Authored-By: Claude Fable 5 <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.
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.
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.
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>
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.
BeginInteractiveRebaseForCommit is used for all the patch commands, and for
rewording. It works by setting the commit we want to stop at to 'edit'; this
doesn't work for merge commits. This wasn't a problem for the patch commands so
far, because you typically don't use custom patches with merge commits (although
we don't prevent this; maybe we should?).
However, it was a problem when you tried to reword a merge commit; this
previously failed with an error, as the test added in the previous commit
demonstrated.
Also, we want to add a new patch command that has to stop *before* the selected
commit (pull patch to new commit before the original one), and this wouldn't
work for the first commit in a feature branch, because it would have to set the
last commit before that to 'edit', which isn't possible if that's a merge (which
is likely).
To fix all this, use a 'break' before the selected commit if the commit is a
merge. It is important that we only do it in that case and not always, otherwise
we would break the new regression tests that were added a few commits ago.
I almost broke this during the development of this branch, so add a test to
guard against that. The point here is that the stack remains intact, i.e. the
renamed commit is the head of the lower branch, and thus shows the "*".
This is very old; I can only guess that this was added at a time where today's
list column handling wasn't in place yet, so the space was needed to separate
columns. This now causes a gap of two spaces between the rebase todo column and
the author column, which I'm sure wasn't intended. Funny that I never noticed.
We treat the .git/sequencer/todo file as read-only. Technically it seems it
would be possible to treat it as modifiable in the same way as
.git/rebase-merge/git-rebase-todo, effectively turning a cherry-pick or revert
that stops at a conflict into an interactive rebase; however, git itself doesn't
allow this (there is no "git cherry-pick --edit-todo"), so it seems safer not to
rely on it.
Theoretically it would be possible to allow modifying the rebase todos when a
cherry-pick or revert conflicts in the middle of a rebase. However, it would
introduce a bit of complexity to support this, as we would have to be able to
distinguish between rebasing todos and cherry-picking/reverting todos, which we
currently can't; it could also be a bit error-prone as far as edge cases are
concerned. And it's really a pretty uncommon situation, so it doesn't seem worth
it, and we just forbid all modifications to todos whenever we are cherry-picking
or reverting.
What happens here is that when stopping on an "edit" todo entry, we rely on the
assumption that if the .git/rebase-merge/amend file exists, the command was
successful, and if it doesn't, there was a conflict. The problem is that when
you stop on an edit command, and then run a multi-commit cherry-pick or rebase,
this will delete the amend file. You may or may not consider this a bug in git;
to work around it, we also check the existence of the rebase-merge/message file,
which will be deleted as well by the cherry-pick or revert.
This problem can't happen inside of lazygit itself right now, but this will
change in the future. It will only happen when you stopped in an interactive
rebase on an "edit" entry, and then you perform a revert or cherry-pick
consisting of more than one commit; in this situation lazygit will show a
conflict although there is none.
This is not possible with lazygit yet, as we don't support range-select for
reverting, and we don't use `git cherry-pick` for cherry-picking. Both will
change in the future, so it's good to fix this bug.
It is useful to see if the conflicted commit was a "pick" or an "edit". What's
more, we're about to add support for showing cherry-picks and reverts, and
seeing that a conflicted commit was a revert is important because its diff is
backwards compared to the diff of the conflicting files in the Files panel.
When you are in the middle of a rebase, and you cherry-pick a commit which
conflicts, it helps to be clear on whether you are prompted to continue the
cherry-pick or the rebase.
The situation is that you perform a rebase, and one of the commits becomes empty
during the rebase, in a way that couldn't be predicted before the rebase
started. To explain: git rebase has some logic where it immediately discards
commits if it can tell that they already exist in the branch being rebased onto;
it does this by looking at the patch ids of the commits to work out which
commits already exist upstream. But for those commits that become empty even
though there isn't a corresponding commit upstream, git stops with an error, and
lazygit detects this (in CheckMergeOrRebaseWithRefreshOptions) and automatically
continues the rebase.
This all works fine; I'm adding this test because I almost broke this during
development of this branch, so I'm adding it to guard against regressions.
We keep the same commit selected (even though its index changed because of the
added update-ref todo), which is nice; however, the main view shows the diff of
the wrong commit, which is very confusing. I'm suprised that this hasn't been
noticed yet.
The reason why this happens is that we first do the refresh, which includes
re-rendering the main view diff (with the same commit index as before, so the
wrong one), and then we restore the correct commit index but don't render the
main view again.
The test demonstrates that the behavior is undesirable right now: we move the
commit only past the update-ref todo of branch1, which means the order of
commits stays the same and only the branch head icon moves up by one. However,
we move the selection down by one, so the wrong commit is selected now. This is
especially bad if you type a bunch of ctrl-j quickly in a row, because now you
are moving the wrong commit.
There are two possible ways to fix this:
1) keep the moving behavior the same, but don't change the selection
2) change the behavior so that we move the commit not only past the update-ref,
but also past the next real commit.
You could argue that 1) is the more desirable fix, as it gives you more control
over where exactly the moved commit goes; however, it is much trickier to
implement, so we go with 2) for now (and that's what the commented-out
"EXPECTED" section documents here). If users need more fine-grained control,
they can always enter an interactive rebase first.
For non-merge commits we change "pick" to "drop" when we delete them. We do this
so that we can use the same code for dropping a commit no matter whether we are
in an interactive rebase or not. (If we aren't, we could just as well delete the
pick line from the todo list instead of setting it to "drop", but if we are, it
is better to keep the line around so that the user can change it back to "pick"
if they change their mind.)
However, merge commits can't be changed to "drop", so we have to delete them
from the todo file. We add a new daemon instruction that does this.
We still don't allow deleting a merge commit from within an interactive rebase.
The reason is that we don't show the "label" and "reset" todos in lazygit, so
deleting a merge commit would leave the commits from the branch that is being
merged in the list as "pick" commits, with no indication that they are going to
be dropped because they are on a different branch, and the merge commit that
would have brought them in is gone. This could be very confusing.
In 67b8ef449c we changed the "edit" command to insert a "break" after the
selected commit, rather than setting the selected todo to "edit". The reason for
doing this was that it now works for merge commits too.
Back then, I claimed "In most cases the behavior is exactly the same as before."
Unfortunately that's not true, there are two reasons why the previous behavior
was better (both are demonstrated by tests earlier in this branch):
- when editing the last commit of a branch in the middle of a stack of branches,
we are now missing the update-ref todo after it, which means that amending the
commit breaks the stack
- it breaks auto-amending (see the added test earlier in this branch for an
explanation)
For these reasons, we are going back to the previous approach of setting the
selected commit to "edit" whenever possible, i.e. unless it's a merge commit.
The only scenario where this could still be a problem is when you have a stack
of branches, and the last commit of one of the branches in the stack is a merge
commit, and you try to edit that. In my experience with stacked branches this is
very unlikely, in almost all cases my stacked branches are linear.
This is very similar to edit_range_select_outside_rebase.go, except that it
selects commits right after, and including, a merge commit.
This test already works correctly. The reason we add it is that we are going to
have two different implementations of the `e` command depending on whether the
last selected commit is a merge commit, and we want to make sure they both work
with a range selection.
Auto-amending is a little-known feature of git that is very convenient once you
know it: whenever you stop at a commit marked with `edit` in an interactive
rebase, you can make changes and stage them, and when you continue the rebase
they automatically get amended to the commit you had stopped at. This is so
convenient because making changes to a commit is one of the main reasons why you
edit a commit.
Unfortunately this currently doesn't work in lazygit because we don't actually
use `edit` to stop at the first commit (instead, we add a `break` todo after it,
which doesn't have the auto-amend functionality).
We'll improve this later in this branch.
Unfortunately it isn't possible to delete them. This would often be useful, but
our todo rewriting mechanisms rely on being able to find todos by some
identifier (hash for pick, ref for update-ref), and exec todos don't have a
unique identifier.
Put it into the individual menu items instead.
Again, this is necessary because we are going to add another entry to the menu
that is independent of the selected branch.
The rebase.updateRefs feature of git is very useful to rebase a stack of
branches and keep everything nicely stacked; however, it is usually in the way
when you make a copy of a branch and want to rebase it "away" from the original
branch in some way or other. For example, the original branch might sit on main,
and you want to rebase the copy onto devel to see if things still compile there.
Or you want to do some heavy history rewriting experiments on the copy, but keep
the original branch in case the experiments fail. Or you want to split a branch
in two because it contains two unrelated sets of changes; so you make a copy,
and drop half of the commits from the copy, then check out the original branch
and drop the other half of the commits from it.
In all these cases, git's updateRefs feature insists on moving the original
branch along with the copy in the first rebase that you make on the copy. I
think this is a bug in git, it should create update-ref todos only for branches
that point into the middle of your branch (because only then do they form a
stack), not when they point at the head (because then it's a copy). I had a long
discussion about this on the git mailing list [1], but people either don't agree
or don't care enough.
So we fix this on our side: whenever we start a rebase for whatever reason, be
it interactive, non-interactive, or behind-the-scenes, we drop any update-ref
todos that are at the very top of the todo list, which fixes all the
above-mentioned scenarios nicely.
I will admit that there's one scenario where git's behavior is the desired one,
and the fix in this PR makes it worse: when you create a new branch off of an
existing one, with the intention of creating a stack of branches, but before you
make the first commit on the new branch you realize some problem with the first
branch (e.g. a commit that needs to be reworded or dropped). It this case you do
want both branches to be affected by the change. In my experience this scenario
is much rarer than the other ones that I described above, and it's also much
easier to recover from: just check out the other branch again and hard-reset it
to the rebased one.
[1]
https://public-inbox.org/git/354f9fed-567f-42c8-9da9-148a5e223022@haller-berlin.de/
It is a bad idea to read a git-rebase-todo file, remove some update-ref todos,
and write it back out behind git's back. This will cause git to actually remove
the branches referenced by those update-ref todos when the rebase is continued.
The reason is that git remembers the refs affected by update-ref todos at the
beginning of the rebase, and remembers information about them in the file
.git/rebase-merge/update-refs. Then, whenever the user performs a "git rebase
--edit-todo" command, it updates that file based on whether update-ref todos
were added or removed by that edit. If we rewrite the git-rebase-todo file
behind git's back, this updating doesn't happen.
Fix this by not updating the git-rebase-todo file directly in this case, but
performing a "git rebase --edit-todo" command where we set ourselves as the
editor and change the file in there. This makes git update the bookkeeping
information properly.
Ideally we would use this method for all cases where we change the
git-rebase-todo file (e.g. moving todos up/down, or changing the type of a
todo); this would be cleaner because we wouldn't mess with git's private
implementation details. I tried this, but unfortunately it isn't fast enough.
Right now, moving a todo up or down takes between 1 and 2ms on my machine;
changing it to do a "git rebase --edit-todo" slows it down to over 100ms, which
is unacceptable.