Compare commits

...
Author SHA1 Message Date
github-actions[bot] 0c908a9461 README.md: Update Sponsors 2026-09-27 09:24:27 +00:00
Stefan Haller cfbbf18656 Delete or push multiple tags at once (#6071)
Make it possible to range-select multiple tags and delete or push them
all at once.
2026-09-27 11:24:08 +02:00
Stefan HallerandClaude Opus 5.5 09b53e9a85 Allow pushing a range selection of tags
Tags can only be pushed one at a time. Let the push command of the tags
panel work on a range selection of tags too. It asks for one remote and
pushes all selected tags to it with a single git command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 3080747fba Let the command for pushing tags take several tags
This lets pushing a range selection of tags push them all with a single
git command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 1b4757be89 Allow deleting a range selection of tags
Tags can only be deleted one at a time. If many of them have piled up,
for example backup tags made before rewriting a branch, deleting them
takes a lot of keypresses.

Let the delete menu work on a range selection of tags. It deletes the
selected tags with one git command for the local tags and one for the
remote tags, and asks for a single remote for all of them. After
deleting local tags, collapse the range selection to its first line;
otherwise it would select the tags that moved up into the place of the
deleted ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 2ca2be6025 Extract the remote prompt and confirmation of deleting a remote tag
Deleting a remote tag and deleting a local and remote tag each have
their own copy of the code that asks for the remote and for a
confirmation. Deleting a range selection of tags needs to change it in
the same way for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 80446461c1 Let the commands for deleting tags take several tags
This lets deleting a range selection of tags delete them all with a
single git command, the same way it works for branches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 fc6ed07747 Generalize showing an inline status on several branches to any items
Deleting and pushing a range selection of tags will need to show their
operation on all the selected tags. Move the code from BranchesHelper
into a generic function that takes the items and the key of the context
that shows them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan Haller 02e103c873 Offer to pull all branches in a stack when pulling the topmost one (#6070)
When somebody else rebases a stack of branches and force-pushes it,
pulling the topmost branch leaves the branches below it diverged. Since
#6067 they can be updated manually with `f`, but pushing the stack
offers to push the branches
below along with the current one, so pulling should be symmetric, and
this PR adds that.
2026-09-27 11:19:22 +02:00
Stefan HallerandClaude Opus 5.5 26ad23a8d2 Offer to update the branches stacked below the current one when pulling
When somebody else rebases a stack and force-pushes it, pulling the
topmost branch leaves the branches below it diverged. They can be
updated with `f`, but pushing the stack offers to push the branches
below along with the current one, and pulling should be symmetric.

When pulling, find the branches stacked below the current one that can
be updated without losing anything. These are those that are behind
their upstream branches, and those that diverged from them only because
they were rewritten. If there are any, show a menu that lists them and
offers to update them in addition to pulling the current branch, or to
pull only the current branch. The current branch is pulled as before.

The decision is based on the last fetch, not on a new one, so that the
menu appears right away. If the remote has changed since then, a branch
might not be offered although it could be updated; it then shows as
diverged after the pull, and pulling again offers it. The update itself
fetches the upstream branches and checks them again, so it never loses
commits.

Branches that are checked out in another worktree are not offered,
since updating them would change the files there.

The branches below are updated before the current branch is pulled. If
one of them pointed into the commits that a rebasing pull rebases, the
pull would move it when rebase.updateRefs is set, and updating it
afterwards would fail. If updating them fails, the current branch isn't
pulled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:13:15 +02:00
Stefan HallerandClaude Opus 5.5 6919a2aa47 Extract showing an inline status on several branches
Pushing a stack and fast-forwarding a range of branches each show their
operation on all the branches involved, with their own copy of the same
code. Pulling a stack will need it too, so move it to BranchesHelper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:13:15 +02:00
Stefan HallerandClaude Opus 5.5 b7395b1637 Split fast-forwarding branches into preparing and running it
FastForwardBranches looks up the worktrees of the branches in the model
on the UI thread, and then does the rest on a worker, with the branches
shown as being fast-forwarded. The next commit needs to update branches
as part of pulling the current one, in the pull's own task.

Move everything except the inline status into PrepareFastForward, which
returns the part to run on a worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:13:15 +02:00
Stefan Haller 9b5b49840b Auto-forward branches even if checked out in another worktree (#6069)
Lazygit can auto-forward main branches after fetching, which is useful
to always keep your `main` branch up to date. However, checking out
`main` in another worktree stopped this from happening, on the
assumption that we don't want to touch branches that are checked out
somewhere. For a main branch this is not an issue (as long as the
worktree doesn't have modified files); forwarding it in that case too is
useful for the setup where you always keep main checked out in the main
worktree, and work on feature branches in linked worktrees.

Pressing `f` on such a branch already supported fast-forwarding it even
when checked out in a worktree, so now auto-forwarding after fetching
does the same.
2026-09-27 11:13:12 +02:00
Stefan HallerandClaude Opus 5.5 0a99f65be3 Auto-forward branches that are checked out in other worktrees
If a main branch is checked out in another worktree, auto-forwarding
after a fetch skips it. People who keep the main branch checked out in
their main worktree, and work on feature branches in linked worktrees,
therefore never get it forwarded.

Fast-forward such a branch in the worktree it is checked out in, as `f`
does. Skip the worktree if it has changes to tracked files, since
nobody asked for its files to change. Also skip it if it is mid-rebase
or mid-bisect, because its HEAD is then detached from the branch. A
branch in the current worktree is never forwarded. This covers the case
of the current worktree being mid-rebase on a main branch, whose ref
would otherwise be forwarded under the rebase.

Auto-forwarding now writes the same reflog message as `f`
("lazygit: update to upstream branch").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:47 +02:00
Stefan HallerandClaude Opus 5.5 1b4fc02040 Keep fast-forwarding the other branches when one of them fails
If `f` is used on several branches that are checked out in other
worktrees, the first worktree in which the fast-forward fails stops the
whole operation. For example, an untracked file that the fast-forward
would overwrite leaves the branches after it untouched, even though
nothing is wrong with them.

Try all the branches, and report the errors of the failed ones
together. The checks that refuse to fast-forward any of the branches
still run before anything is moved.

The next commit uses forwardBranches for auto-forwarding branches in
other worktrees, where stopping at the first failure is worse. A
worktree that fails every time would prevent the ones after it from
ever being forwarded. For this, forwardBranches also takes the git
commands to use, since a background worker doesn't block switching
repos.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:47 +02:00
Stefan HallerandClaude Opus 5.5 245a613762 Auto-forward branches on a worker
AutoForwardBranches runs in the Then callback of the refresh after a
fetch. This is on the UI thread, so the git command for updating the
branches blocks the UI while it runs. This is fine for a single
update-ref, but the next commit adds a git status and a git merge for
each branch that is checked out in another worktree.

Keep reading the branches from the model on the UI thread, and move the
git work to a worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:47 +02:00
Stefan Haller acc58a2d12 Use f to "fast-forward" branches whose upstream branch was rewritten (#6067)
Lazygit shows local branches that have diverged from their upstream with
a yellow `↓3↑5`. There can be two reasons for diverging, and right now
it's impossible to tell which of these is the case: (1) you rebased the
branch locally (or rewrote its history), in which case you want to
force-push it, or (2) someone else rebased the branch, in which case you
want to pull it. (We'll ignore the case where both of these happened, in
which case you need to manually reconcile the work that each side did,
e.g. through cherry-picking; you want to avoid this situation.)

With this PR, lazygit distinguishes the two scenarios, and for (2) it
shows the `↓3↑5` in a dim yellow. What's more, you don't even have to
check out the branch to pull it; you can press `f` to reset it to its
upstream without checking it out, like you would fast-forward a branch
that is behind its upstream. In fact the command is still called
"Fast-forward", which is technically not quite correct, but it feels the
same to me. Fast-forwarding a diverged branch in this way is very useful
if an entire stack of branches was rebased remotely; you can simply
range-select all those branches and hit `f` to bring them up to date
with their upstreams.
2026-09-27 11:05:44 +02:00
Stefan HallerandClaude Opus 5.5 ef00e18247 Refuse to fast-forward a branch being rebased or bisected in a worktree
The worktree loader associates a worktree that is mid-rebase or
mid-bisect with the branch involved, even though its HEAD is detached.
Fast-forwarding such a branch therefore ran the fast-forward in that
worktree. This moved the detached HEAD and put the upstream commits
into the rebase in progress, while the branch stayed where it was.

Remember in the worktree model when its branch comes from a rebase or
bisect, and refuse to fast-forward the branch in that case. Updating
only the ref is no option for a rebase; the rebase writes the branch
when it finishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5.5 5990946a23 Add a test for fast-forwarding a branch being rebased in a worktree
If a branch is being rebased in a worktree, pressing `f` on it moves
the detached HEAD of that worktree instead of the branch. The upstream
commits end up in the rebase in progress, and the branch itself stays
where it was. The same happens for a branch being bisected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 9183269ee8 Write a reflog message when moving branch refs
Branches that lazygit forwards to their upstream move by a direct ref
write, so no git command turns up in their reflog to explain it. Until
now the entry had no message either, leaving `git reflog <branch>` with
nothing but a blank line about it. Name lazygit and what it did, so that
a branch which moved on its own can be traced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 83bff569a0 Allow fast-forwarding a range of branches
After somebody else rebased and force-pushed a stack of branches, every
branch of the stack has to be brought back to its upstream, and pressing
`f` on them one by one is as tedious as the checking out and pulling it
replaces.

Let `f` work on a range selection. The upstream branches are fetched with
one `git fetch` per remote, and the branches that aren't checked out
anywhere move in a single `git update-ref` call. All the selected
branches are looked at before any of them is moved, so a branch that has
to be refused leaves the others alone rather than updating the stack
halfway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 78dc65cbeb Fast-forward branches whose upstream branch was rewritten
When somebody else rebases a branch and force-pushes it, the local
branch is left diverged from it, so `f` refuses to touch it. To get back
in sync, the branch has to be checked out and pulled, and for a stack of
branches that means doing it once per branch.

Such a branch has none of its own work in it, though: the commits it is
ahead by are the old versions of the ones that are now on the remote
branch. So when the branch has diverged and none of the commits it is
ahead by is ours, reset it to its upstream instead of refusing. This is
the same state that checking the branch out and pulling it would produce,
as rebase drops commits that the upstream already has.

A branch that isn't checked out anywhere is reset with the same
`git update-ref` that a fast-forward uses. One that is checked out
somewhere is reset with `git reset --keep`, and only when that worktree
has no changes to tracked files, so that the files under the user's feet
change no more than they have to. `--keep` also refuses to overwrite an
untracked file that the reset would bring in, which `--hard` would do
silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 667df9e784 Split fast-forwarding a branch into fetching and updating it
Fast-forwarding a branch that isn't checked out ran a single
`git fetch <remote> refs/heads/<branch>:<branch>`, so git's own refusal
to move a local branch backwards served as the safety check. A later
commit widens the set of branches the command accepts, and for that
lazygit has to make that decision itself.

Fetch the remote branch on its own, updating only its remote-tracking
branch. Then ask git whether the branch is an ancestor of it, and only
then move the branch: with `git update-ref` when it isn't checked out
anywhere, guarded by the hash we knew it to have, and with
`git merge --ff-only` in its worktree when it is checked out there.
Keeping `git pull --ff-only` for the latter case would fetch a second
time.

Fast-forwarding a branch that isn't checked out in a worktree had no
integration test at all, so add one. The second new test covers a repo
that keeps no reflogs, because a later commit starts reading those and
this case has to keep working without them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 0d8b18cfb7 Move fast-forwarding a branch into BranchesHelper
Most of the logic of this controller belongs in a helper, and the next
commit reshapes it, so move it over unchanged first to keep that diff
about the change itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 503ca22d09 Generalize CanDoFastForwardMerge into IsAncestor
The function asks git whether HEAD is an ancestor of a ref, and its name
says what the one caller wants to know. A later commit asks the same
question about a branch that isn't checked out, so let the caller name
both refs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 3ff0fdbcc1 Dim the divergence of a branch whose upstream was rewritten
When somebody else rebases a branch and force-pushes it, our branch
shows up as diverged, say ↓5↑3, and that looks exactly like a branch
that carries three commits of our own. The two want different treatment.
The first one only has to be moved to its upstream, while the second one
needs a decision about what to do with those commits. The panel doesn't
tell them apart, so users have to look at each branch in turn to find
out which one they are dealing with.

Determine for every diverged branch whether it has commits of its own,
and dim its divergence when it doesn't. This runs on a worker, as it
takes a few git commands per diverged branch, and a branch keeps the
answer from the previous load while a new one is in flight, so that the
display doesn't flicker. A later commit teaches the fast-forward command
to act on such a branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 349bcc3691 Rename the loadBehindCounts parameter to loadExtraInfo
The flag says whether this load is the one that kicks off the values
determined in the background afterwards. A later commit adds a second of
those next to the behind-counts, so name the flag after what it controls
rather than after its only user so far.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan HallerandClaude Opus 5 173001c3e7 Add a function to tell whether a branch has commits of its own
A branch that has diverged from its upstream doesn't necessarily hold any
work of its own. If somebody else rewrote the remote branch and
force-pushed it, our branch is still at the commits it had before, and
every one of them was on the remote branch at some point. A later commit
offers to reset such a branch to its upstream, and for that it has to
tell this case apart from a branch that holds commits created here.

The reflog of the remote-tracking branch records the values it had before
it was rewritten, so a commit that was ever on the remote branch is
contained in its current value or in one of those. HasLocalOnlyCommits
asks git for a commit of the branch that none of them contains.

Two details of the reflog are worth knowing. The value the ref had before
its oldest entry is that entry's old value, and <ref>@{<number of
entries>} is the only way to name it. It doesn't exist when the oldest
entry is the one that created the ref, and asking for it then is an
error, not an empty result. Reflogs can also be missing altogether,
because core.logAllRefUpdates defaults to false in a bare repository.
Neither case must stop us from recognizing a branch that is strictly
behind its upstream, so the current value of the remote-tracking branch
is always used; missing previous values only make the answer more
conservative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:00:36 +02:00
Stefan Haller 93fb28a864 Push all branches of a rebased branch stack at once (#6045)
After rebasing a stack of branches, every branch of the stack has to be
force-pushed, and so far the only way to do that in lazygit was to check
out each branch in turn and push it. For a stack of a dozen branches
that is a lot of work for a routine task.

When the current branch is pushed and there are branches below it in the
stack that have commits to push, show a menu that offers to push them
along with it. The menu lists the branches and how far each of them has
diverged from its remote branch, so that the user can see what is about
to happen. It doesn't show where each branch goes (i.e. what its
upstream branch's name is or on which remote it is stored); the branches
panel doesn't show that for a normal push either. If any of the branches
has diverged from its remote branch, a single confirmation covers
force-pushing all of them (listing exactly the ones that need
force-pushing).

This only covers branches that already have an upstream configured,
because a branch that was never pushed yet
can't be told apart from one that is meant to stay local. This means
that the very first time you want to push a newly created stack, you
still need to do it manually the old way. We can see if we want to
improve this somehow in the future, but repeatedly pushing a rebased
stack is the more frequent operation, and that's what we improve here.

Pushing the current branch on its own still runs a bare `git push`, so
users who rely on `push.default` or `remote.<name>.push` for it see no
change. The push is non-atomic, as git defaults to; if one branch's
lease fails, the others still go through and the error names the one to
look at.

Some repositories are configured to reject pushes of more than x
branches at once; for those, the command fails with an error. We could
be smarter about this, detect the error, and fall back to pushing each
branch one by one, but I first want to see how many reports we get about
this before investing in the extra logic.
2026-09-27 11:00:32 +02:00
Stefan HallerandClaude Fable 5.1 149c5f8578 Ignore escape sequences when wrapping text for a view
WrapViewLinesToWidth counted the characters of ANSI escape sequences as
if they were visible, so colored text wrapped earlier than the view
does, and the number of lines it reported was too high. In a narrow
terminal the divergence in the menu that offers to push a stack of
branches, colored yellow, ended up on a line of its own even though it
fitted. The tooltip of a disabled menu item, whose prefix is red, gets
its height from the same function.

Skip escape sequences when measuring the width, and never break a line
inside one. gocui wraps parsed cells and never sees escape sequences, so
this brings the two in line; the test's parity check now compares the
wrapped lines with the escape sequences stripped.

Only CSI sequences are recognized. Those are the color and style codes
that lazygit puts into view content; hyperlinks (OSC 8) only occur in
the main view, and gocui wraps that one itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan HallerandClaude Fable 5.1 9a585bf11e Offer to push the branches stacked below the current one
After rebasing a stack of branches, every branch of the stack has to be
force-pushed, and so far the only way to do that in lazygit was to check
out each branch in turn and push it. For a stack of a dozen branches
that is a lot of work for a routine task.

When the current branch is pushed and there are branches below it in the
stack that have commits to push, show a menu that offers to push them
along with it. The menu lists the branches and how far each of them has
diverged from its remote branch, so that the user can see what is about
to happen. It doesn't show where each branch goes; the branches panel
doesn't show that for a normal push either. Pushing only the current
branch is the second entry. If any of the branches has diverged from its
remote branch, a single confirmation covers force-pushing all of them.

A branch is offered if its tip is a commit of the current branch that
isn't merged yet, it has an upstream that is stored locally, that
upstream is not gone, and it is ahead of its push destination. Branches
without an upstream are left out because a branch that was never pushed
can't be told apart from one that is meant to stay local. A branch that
is only behind is left out because pushing it would move the remote
branch back to an older commit; the lease doesn't catch that when the
remote-tracking branch is up to date.

Each branch is pushed to where `git push` would push it if it were
checked out, using the destination git reports in the %(push) field.
This honors push.default, remote.pushDefault and
branch.<name>.pushRemote without lazygit having to interpret them.
Branches going to the same remote are pushed in one command, with
--force-with-lease when the user confirmed force-pushing; the lease
checks each ref against its remote-tracking branch, so a coworker's
unseen push is still rejected. The current branch joins that command
when its remote-tracking branch is stored locally. Otherwise it needs a
plain push first, for example with --set-upstream, and that option
would apply to every refspec of a combined command; so in that case it
is pushed on its own as before, and the other branches follow in a
second command.

Pushing the current branch on its own still runs a bare `git push`, so
users who rely on push.default or remote.<name>.push for it see no
change. The push is non-atomic, as git defaults to; if one branch's
lease fails, the others still go through and the error names the one to
look at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan HallerandClaude Fable 5.1 bfb8c91b7b Split pushing the current branch into resolving its destination and pushing
Pushing the current branch has three cases: it has an upstream, it has
none but push.default is "current", or the user is prompted for one.
Each case ends in a push, and the first one also checks whether a force
push is needed. Let the three cases call a common callback instead, and
put the check and the push there. This makes it possible to run the same
three cases with a different callback, so that the branches stacked
below the current one can be pushed along with it.

The check returns false for a branch without an upstream, so the other
two cases behave as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan HallerandClaude Fable 5.1 c3b09cebad Add a function to find the branches stacked below a branch
lazygit will offer to push these along with the current branch, so that
a rebased stack can be pushed in one go. The commits panel already marks
the tips of stacked branches, and this uses the same criterion. A branch
is below the given one if its tip is one of the loaded commits of that
branch that isn't merged into a main branch. This needs no git command.
In whole-graph mode the commit list also contains commits of other
branches, but those are marked as merged, so they don't count.

Not used yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan HallerandClaude Fable 5.1 6378618544 Generalize the push command to take a remote and a list of refspecs
PushOpts can only describe a push of the current branch. It takes the
branch's name and the upstream to push it to, and builds a single
refspec from them. Pushing several branches in one command needs one
refspec per branch, so let callers pass the refspecs themselves. The
sync controller, the only caller so far, builds the same single refspec
that PushCmdObj built before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan HallerandClaude Fable 5.1 fa427c32b3 Store each branch's push destination in the branch model
To push a branch other than the checked-out one, lazygit has to name the
remote and the remote branch in the push command; a bare `git push` only
works for the current branch. The upstream isn't always the right
destination. In a triangular workflow the branch is pushed to a
different remote than it pulls from, and with push.default set to
"current" it goes to a branch of the same name whatever the upstream is
called.

Read the destination from git instead of working it out from the config.
The %(push) field of for-each-ref names the remote-tracking ref that a
push would update, taking push.default, remote.pushDefault and
branch.<name>.pushRemote into account. It is also the ref that the
push:track counts are computed against, so the force-push detection and
the destination agree. The field is available since git 2.5, well before
the oldest version lazygit supports.

Nothing uses the new fields yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00
Stefan Haller 58fe972a14 Add dark and light color themes (#6065)
Make it possible to override `gui.theme` colors for light or dark
backgrounds, by setting them in `gui.darkTheme` or `gui.lightTheme`,
respectively; this is often necessary when there isn't a single color
that looks good in both.

Use this to provide better defaults for the background color of a
selected line, and to add a color for the background of an inactive
selected line (used in unfocused views), which didn't have one by
default.

Also, provide a way to turn off the "bold" highlighting of the selected
line, which was hard-coded and couldn't be changed; there's a new config
setting `gui.theme.selectedLineFgColor` that is `[bold]` by default (so
existing behavior is unchanged), but can be set to `[default]` to remove
the bold. This addresses #2304.
2026-09-27 08:20:07 +02:00
Stefan HallerandClaude Opus 5.5 5196c4fab5 Let users turn off the bold text of the selected line
The text of the selected line is always bold. Some users don't like
this (#2304), but there is no way to turn it off. Setting
selectedLineBgColor doesn't help. Its attributes apply to the text too,
so it can add bold, but it can't take it away.

Add gui.theme.selectedLineFgColor for the text of the selected line, in
focused and unfocused views alike. Its attributes are added to those of
the text, and a color replaces the colors of the text. It is [bold] by
default, so nothing changes unless you set it; 'default' leaves the
text as it is.

Putting bold into the default of selectedLineBgColor instead wouldn't
work well. To turn it off, you would have to replace the whole list, and
lose the color that is computed from the terminal's background.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 c1b14ea2af Stop brightening the text of the selected line
gocui draws the palette colors 0 to 7 on the selected line in their
bright variants. This was meant for the blue highlight on a dark
background, but in most dark palettes the bright variants are so close
to the normal ones that it's barely visible. Elsewhere it makes the text
harder to read. In many palettes, the bright variants are lighter, and
they don't work on the light highlight of a light background. Solarized
maps most of its bright colors to its grays, so colored text on the
selected line turns gray.

Draw the text of the selected line in the same colors as on the other
lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 61f808ce90 Pick a selection color that is readable on light backgrounds
On a light background, the selected line is black text on the palette's
blue. With most palettes, this is hard to read. Light palettes make all
their colors dark enough to read as text on the light background, so
none of them works well as a background for text.

On a light background, mix 25% of #0064ff into the terminal's background
instead. On white, this gives #bfd8ff. The colored text on the selected
line then stays as readable as it is elsewhere.

On a dark background, keep the palette's blue. Dark palettes make it
dark enough to work as a background, and it keeps working on terminals
with only 8 colors. There, a color mixed from a dark background would
turn into black.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 f75038abf4 Draw the borders of inactive windows in faint text
The borders of inactive windows are drawn in the terminal's default text
color, so they are as prominent as the text inside the windows. Draw
them in faint text instead. The terminal shades faint text toward its
background, so this works on dark and light backgrounds alike, and also
on backgrounds that are neither black nor white. The titles of inactive
windows use the same color, so they are dimmed too.

Terminals that don't support faint text draw the borders in the default
color, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 ca950181e8 Pick the built-in theme defaults for the terminal's background
Some theme colors can't have one default that suits both dark and light
backgrounds. A gray background for the selected line of an inactive
view has to be lighter than the terminal's background if that is dark,
and darker if it is light.

Give such fields a default for each kind of background. Derive it from
the terminal's background color if the terminal tells us, so that it
keeps the same distance from the background however dark or light that
is, and takes on its tint. Otherwise, assume a black or white
background.

These defaults can't be the defaults of gui.theme. We would then have
to tell whether a value there came from the user or from the built-in
defaults, because only the user's value should win over a background
default. Keep them apart from the user config instead, and leave these
fields empty in the defaults of gui.theme, so that a value there always
comes from the user. Tests ensure that no field has both kinds of
defaults, and that both kinds of background set the same fields.

Start with inactiveViewSelectedLineBgColor: the background mixed with
30% white if it is dark (#4d4d4d on black), or with 15% black if it is
light (#d9d9d9 on white). A gray background shows where the selection
is in a view without the focus more clearly than the bold text we used
so far.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 34c86efc67 Show commits outside the bisect range in faint text
While bisecting, the hashes of the commits outside the bisect range are
black. On a dark gray background this makes them recede, but on a black
one they are invisible, and on a light one they stand out more than any
other hash.

Draw them in faint text instead. The terminal shades faint text toward
its background, whatever that is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 8e1f652a35 Allow dim in theme colors
The faint attribute makes text recede on dark and light backgrounds
alike, without having to pick a gray for each. Let users use it in the
theme.

Some theme colors go through both GetTextStyle and GetGocuiStyle, so
both have to know it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 c236214c29 Let strikethrough work in the theme colors that gocui draws
Config.md lists strikethrough as a modifier for theme colors, but only
GetTextStyle knows it. GetGocuiStyle turns an unknown name into white,
and white OR-ed with a palette color is white. So if you set a border
color to [red, strikethrough], you get a white border without
strikethrough. The same goes for the parts of the selected line,
options text and default text colors that gocui draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5 679fe58e8e Add a dim text style
A later commit shows the hashes of commits outside the bisect range in a
dimmer variant of the default text color. A darker shade of a terminal
palette color can't be derived, because we don't know what the palette
is, so leave the shading to the terminal and use its faint attribute,
SGR 2. In gookit/color that attribute is called OpFuzzy.

Terminals that don't implement SGR 2 render the text in its normal color.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 0ca4fdefba Add gui.darkTheme and gui.lightTheme
A color that reads well on a dark background can be hard to read on a
light one, and the other way round. If you switch your terminal between
dark and light, there is often no single set of theme colors that works
for both (#4366).

Add two overrides of gui.theme, one for each kind of background.
gui.colorScheme, or else what the terminal tells us, decides which one
applies. A field that is set in the override replaces the one in
gui.theme. Author colors and branch color patterns are merged by entry
instead, so that an override doesn't have to repeat the entries it
doesn't change.

When the terminal's background changes, re-apply the theme and
re-render all views, not only the ones that show author colors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 6831c47115 Let a config struct appear at more than one path in the schema
We are about to add gui.darkTheme and gui.lightTheme, with the same type
as gui.theme. The schema generator stores a struct type as one
definition that all its properties refer to, and setDefaultVals writes
the defaults of each path into that definition. The overrides would
then claim the defaults of gui.theme, both in the schema and in
Config.md.

Give each property whose struct definition is shared a copy of its own.
In Config.md, print only the description of every copy after the first,
so that the fields aren't listed several times.

Nothing in the config shares a struct definition yet, so the generated
files don't change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 2d72387b19 Rename setColorScheme to applyTheme
Since gui.colorScheme exists, the name reads as if the function set that
config. That gets more confusing once gui.colorScheme decides which
theme the function applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 51002cc473 Move gui.authorColors and gui.branchColorPatterns into gui.theme
We are about to add overrides of gui.theme for dark and light
backgrounds. Author and branch colors need them too, because a color
that reads well on a dark background may be hard to read on a light
one. Move them into gui.theme, so that the overrides cover them without
a mechanism of their own.

The migration of gui.branchColors creates gui.branchColorPatterns, so it
now has to run before the moves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 8e396228e9 Try branch color patterns in the order they are written
If several patterns in gui.branchColorPatterns match a branch, the color
it gets is picked at random, and it can change from one render to the
next. The patterns are kept in a Go map, and Go randomizes the order in
which a map is iterated.

Keep the patterns in a list instead, in the order in which they are
written, and let the first match win. If a repo's config file has
patterns too, put them in front of those of the global config file,
because they are more specific.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 ae1b43dc96 Extract converting a configured color to a text style
The next commit needs it for a single color.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan HallerandClaude Opus 5.5 573cd8a005 Migrate gui.branchColors to gui.branchColorPatterns
gui.branchColors has been deprecated in favor of gui.branchColorPatterns
since 0.44.0. We are about to move gui.branchColorPatterns into
gui.theme, and the deprecated key would have to move along with it.
Migrate it instead, so that we can remove it.

gui.branchColors matched its keys against the part of a branch name
before the first slash. The pattern ^<key>(/|$), with the key escaped,
matches the same branches.

If gui.branchColorPatterns is set, gui.branchColors has no effect; in
that case the migration removes it. This check is done per file. So if
the global config sets gui.branchColorPatterns and a repo config sets
only gui.branchColors, the repo's colors were ignored so far, and now
they apply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:06 +02:00
Stefan Haller 5ed0345899 Allow telling diff renderers whether the terminal is dark or light (#6063)
Renderers like delta and difftastic pick their colors for either a dark
or a light background, and they can't find out which one the terminal
has, because lazygit runs them with TERM=dumb, and in a pty that doesn't
answer their queries.

Add `{{colorScheme}}` to the commands of diff renderers. It is 'dark' or
'light', based on what the terminal reports (which can be overridden by
`gui.colorScheme` if the terminal doesn't support the query). It can be
passed to delta as `--{{colorScheme}}` and to difftastic as
`--background={{colorScheme}}`; other renderers can choose between
options with a template expression.
2026-09-27 08:16:03 +02:00
Stefan HallerandClaude Opus 5.5 d38248d851 Tell diff renderers whether the terminal is dark or light
Renderers like delta and difftastic pick their colors for either a dark
or a light background, and they can't find out which one the terminal
has. Lazygit runs them with TERM=dumb, in a pty that doesn't answer
their queries. So the colors come from the config, and when the
terminal switches between dark and light, the diff keeps the ones it
has.

Add {{colorScheme}} to the commands of diff renderers. It is 'dark' or
'light', going by gui.colorScheme, or by the terminal if that is
'auto'. It can be passed to delta as --{{colorScheme}} and to
difftastic as --background={{colorScheme}}; other renderers can choose
between options with a template expression. When the terminal switches
between dark and light, render the diff again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:45 +02:00
Stefan HallerandClaude Opus 5.5 2c91bd99d6 Move re-rendering the main view after a diff renderer change to a helper
The next commit needs to render the diff again when the terminal
switches between dark and light, with the same care not to replace
whatever else the main view might show.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:45 +02:00
Stefan HallerandClaude Opus 5.5 c8a4c8d392 Let the command of a diff renderer be a template
The command of a diff renderer can refer to values like the width it
renders at, as {{width}}. They are filled in by plain replacement, so a
command can't choose between options depending on them. The next commit
adds a value that needs this: whether the terminal is dark or light.
delta takes --dark or --light, but for other renderers the choice has to
be spelled out differently, for example as the name of a syntax theme.

Resolve the command as a Go template instead. The values become its
variables, so that {{if gt .width 160}} --side-by-side{{end}} works too.
To keep the existing commands working, a variable can still be written
without the leading dot.

A mistake in a template, such as a misspelled variable, now makes
resolving the command fail, instead of leaving the placeholder in it.
Check the commands when the config is loaded, by resolving each of them
with made-up values, so that the mistake shows up as an invalid config.
This also rejects a variable that the kind of renderer doesn't have,
such as {{columnWidth}} in the command of an external diff; until now,
it reached the renderer as it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:45 +02:00
Stefan HallerandClaude Opus 5.5 4ce8d77173 Resolve the commands of all kinds of diff renderers in one place
The getters for the stdin filter and the external diff command each
take the values they fill in as parameters of their own, and each
builds the placeholders for them. The next commit checks the commands
when the config is loaded, and for that it needs to resolve a command
whatever its kind. A value that both kinds can use comes after that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:45 +02:00
Stefan Haller d2519cdf26 Improve the randomly picked author colors (#6062)
Lazygit automatically picks random colors for the authors in the Commits
log. On a dark terminal theme, many of these were too dark and barely
readable; this can also be seen in the demo videos on the lazygit entry
page. Improving this is not trivial, because a range of colors that
looks good on a dark background comes out too pale on a light
background.

To improve this, detect the terminal's background color (with a config
override for those terminals that don't report it), and pick different
ranges of colors depending on that. In later PRs we will use the same
background detection to allow passing `--dark` or `--light` to a diff
renderer, and to support different `gui.theme` configurations for light
and dark.

While at it, fix the problem that the `gui.authorColors` config for
manually overriding some of the random colors didn't update on a config
reload.
2026-09-27 08:09:41 +02:00
Stefan HallerandClaude Opus 5.5 f51c4aaefc Pick the colors of authors for the terminal's background
The colors that lazygit derives from the names of authors are picked
for a dark background. On a light one, they are too pale to read;
against white, every author has a contrast ratio between 2.2:1 and
3.5:1.

Now that lazygit knows whether the terminal is dark or light, pick them
from a darker range of lightness when it is light. Against white, the
contrast ratio is now between 5.2:1 and 9.2:1, and against the
background of Solarized Light between 4.8:1 and 8.5:1. On a dark
background, nothing changes. If the terminal switches between dark and
light while lazygit is running, draw the commits again in the new
colors.

Add gui.colorScheme for terminals that don't tell us, and for anyone
who wants to override what they tell. It is 'auto' by default; 'dark'
and 'light' ignore what the terminal says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:58:33 +02:00
Stefan HallerandClaude Opus 5.5 7282c21aa5 Derive author colors in a color space where lightness means brightness
If gui.authorColors names no color for an author, lazygit picks one
from a hash of their name, at an HSL lightness between 0.4 and 0.6.
HSL lightness says nothing about how bright a color looks, though. At
0.5 a yellow is glaring and a blue is nearly black, so whether an
author's initials can be read comes down to where their name happens
to hash to. Against a background of #1e1e1e, 45% of four thousand
names fall below a contrast ratio of 4.5:1 and 21% below 3:1, with the
worst at 1.46:1. Several issues have been raised about author colors
being too dark to read.

Use HSLuv instead. Its lightness tracks perceived brightness, so the
range can be narrow now that a number in it means something. Against
#1e1e1e, every author now lands between 4.7:1 and 7.7:1, so no name is
unlucky any more.

This changes the color of every author who isn't named in
gui.authorColors. On a light background, the new colors are uniformly
too pale, where before they were mostly too pale. The next commit gives
a light background a range of its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 d4f6e77d94 Add a tool that creates a repository of authors at the color extremes
If gui.authorColors names no color for an author, lazygit derives one
from a hash of their name. Checking whether these colors are readable
means looking at a repository, but the authors of a real one rarely
land near the edges of the range, so the worst cases go unseen.

Add cmd/author_colors_repo. It searches for names at the lowest and
highest lightness and saturation, at twelve hues, and commits once as
each of them. The names depend only on the hashing, so the same
repository shows the colors of any lazygit build and can be kept
around to compare them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 fcabbf517c Split an author's color into where the name lands and the color there
The next commit adds a tool that creates a repository of authors at
the edges of the range their colors are picked from. To find such
names, it needs to know where a name lands in the range, and it
shouldn't keep a copy of the hashing that could drift from this one.
Later commits test the colors, and need the colors themselves for that
rather than the styles made from them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 c06233073a Let author colors follow a change of gui.authorColors
When the config is reloaded, SetCustomAuthors replaces the styles of
authors, but the initials and names that were rendered with the old
styles stay cached. So do the pipes of the commit graph; each of them
carries the style of the author of the commit it starts at.

Drop these whenever the colors of authors change. The graph's cache
finds out by itself, by comparing a version number, so that nothing
has to remember to reset it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 46761ad2e5 Demonstrate that author colors don't follow a change of gui.authorColors
If gui.authorColors changes while lazygit is running, the authors that
are already on screen keep their old colors, both in the author column
and in the commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 771e75c8ab Keep the configured author styles apart from the derived ones
The styles from gui.authorColors and the ones derived from the names
of the other authors share a map, so the derived ones can't be dropped
without the configured ones. A later commit needs to drop the derived
ones on their own, when the terminal switches between dark and light.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan HallerandClaude Opus 5.5 65efd3dde5 Detect whether the terminal is dark or light
Lazygit doesn't know whether the terminal it runs in has a dark or a
light background. The colors of authors, for example, have to suit one
or the other, and are too pale to read on a light background.

Ask the terminal. Many terminals answer CSI ? 996 n with whether they
are dark or light, and send the same report again whenever that changes
while mode 2031 is on. Terminals differ in what they base this on,
though. kitty goes by its background color, but Ghostty goes by the dark
or light mode of the operating system, even with a theme that doesn't
follow it. So also ask for the background color with OSC 11. If the
terminal answers that, the background decides, and a report only makes
us ask for the background again. Some terminals answer OSC 11 but send
no reports; ask these for the background again whenever the terminal
gains focus.

Turn mode 2031 off whenever lazygit hands the terminal to another
program, so that the program doesn't receive the reports as typed text.
For the same reason, first wait for the answers to any queries that are
still outstanding, but for no longer than half a second. Locally, the
answers take a few milliseconds at most, but over ssh they take a
network round trip, and focusing the terminal and then pressing a key
that starts an editor fits into that.

Leave out the terminals that tcell doesn't send its own queries to,
except for Terminal.app and WezTerm; these are only asked for the
background color.

This doesn't make startup any slower. tcell sends its own queries in
Screen.Init and waits for the terminal to answer them. Ours go out just
before, so their answers arrive before tcell stops waiting, and the
color scheme is known before the first layout.

tcell has no support for any of this, and drops the answers when it
parses its input. Instead of adding support to tcell, wrap the tty that
tcell reads from and watch the input as it goes by.

For now, lazygit only writes the color scheme to the debug log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:17:21 +02:00
Stefan Haller 1b39e38ae6 Keep coverage errors out of the output of the pipeline tests (#6061)
On the Windows CI job,
TestStartPipelineReadsWhatTheCommandsComplainAbout fails now and then
because the output it reads has an extra line after the expected one.
The line starts with "error: coverage meta-data emit failed" and ends
with "The process cannot access the file because it is being used by
another process."

The pipeline tests run the test binary itself as the members of the
pipeline. CI builds it with -cover, and the members inherit GOCOVERDIR
from go test, so each of them writes coverage data to that directory
when it calls os.Exit. The meta-data file has the same name for every
process of one binary, and every process replaces it by renaming a new
copy onto it. (Go's check for an existing file compares its size against
the wrong length, so it never finds one.) On Windows this rename fails
if another process holds the file open. In this test both members exit
at the same time, and the runtime prints the failure to stderr.
StartPipeline puts every member's stderr into the output that the test
compares.

Exit the members with syscall.Exit instead. It skips the runtime's exit
hooks, so the members write no coverage data at all. Nothing is lost by
this. Their coverage data went to a temporary directory of go test, not
to the directory that CI uploads.

This fixes a regression introduced with #6025.
2026-09-26 12:16:46 +02:00
Stefan HallerandClaude Opus 5.5 f9eb2090a6 Keep coverage errors out of the output of the pipeline tests
On the Windows CI job,
TestStartPipelineReadsWhatTheCommandsComplainAbout fails now and then
because the output it reads has an extra line after the expected one.
The line starts with "error: coverage meta-data emit failed" and ends
with "The process cannot access the file because it is being used by
another process."

The pipeline tests run the test binary itself as the members of the
pipeline. CI builds it with -cover, and the members inherit GOCOVERDIR
from go test, so each of them writes coverage data to that directory
when it calls os.Exit. The meta-data file has the same name for every
process of one binary, and every process replaces it by renaming a new
copy onto it. (Go's check for an existing file compares its size against
the wrong length, so it never finds one.) On Windows this rename fails
if another process holds the file open. In this test both members exit
at the same time, and the runtime prints the failure to stderr.
StartPipeline puts every member's stderr into the output that the test
compares.

Exit the members with syscall.Exit instead. It skips the runtime's exit
hooks, so the members write no coverage data at all. Nothing is lost by
this. Their coverage data went to a temporary directory of go test, not
to the directory that CI uploads.

The test was added in dfd6a7dbf2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 12:10:37 +02:00
Stefan Haller a82c0d0122 Render diffs without a PTY on Windows (#6025)
In #5740 we implemented PTY support for Windows; back then we thought
this is a prerequisite for supporting custom diff renderers (which were
still called "custom pagers" back then), because git will only use the
GIT_PAGER env var when it is running in a PTY. The problem is that the
Windows PTY, being based on ConPTY, does not behave like a Unix PTY,
which basically just passes through all data from the client. ConPTY
renders what it receives from the client into its own screen buffer, and
then re-encodes it from there for the terminal side. This has already
caused problems that are awkward to work around (e.g. ConPTY will
convert a series of multiple blank lines to a cursor positioning escape
sequence, so we need to parse that and convert it back, see
180fe0cd26); but now, with the upcoming OSC 1717 work, it turns out
that it's impossible to attach OSC 1717 metadata records to the cells
they belong to, because ConPTY sends those immediately to the terminal,
but the rest of the cell data some time later, and it's impossible to
reconstruct the original stream.

So use an ordinary pipe on Windows, where we start git and the diff
renderer on our side instead of telling git to drive the renderer. It's
a shame that we didn't realize it's possible; we could have done this
years ago without having to wait for a working Windows PTY.

One downside is that the diff renderer can no longer ask the terminal
how wide it is, so if it needs to know that (e.g. for a side-by-side
diff, or for horizontal lines that should be as wide as the view), then
it needs another way to find out. We set the `COLUMNS` environment
variable, which delta, difftastic and diff-so-fancy all support in their
latest versions, and for those renderers that don't, we provide a
`{{width}}` template variable that can be used in a diff renderer
command to pass it as a command-line argument.
2026-09-25 12:14:17 +02:00
Stefan HallerandClaude Opus 5 7a0f475414 Let a render refresh the index again on Windows
Renders were kept from refreshing git's index because a pty-rendered
command on Windows is terminated at an arbitrary instruction when its
task stops, and one landing in the window where git holds index.lock to
write back refreshed stat information leaves that lock behind.

A render no longer runs in a pty there, and nothing kills it any more
either: the pipe to the renderer breaks, git's write fails, and git dies
through its own die path with its lock files cleaned up. So let the
refresh happen, and let renders heal stale stat info the way they do
everywhere else.

The teardown of a pseudoconsole took its reassurance about index.lock
from this, and renders were the reason it held. What still runs in a pty
there is a custom command asking for logWithPty and a command that may
be asked for a credential, so the claim no longer follows; drop it
rather than restate it for clients it was never about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 087bdcd57f Render a diff without a pty on Windows
ConPTY doesn't carry a diff renderer's output to us as the renderer
wrote it. It parses the output into a screen buffer and re-encodes that
for the terminal side, and a sequence it can't represent there goes out
the moment it is parsed, separately from the text around it. The OSC
1717 records a renderer states its diff lines in therefore arrive
detached from the rows they describe, and the identity layer attributes
rows to the wrong diff line or to none.

Feed the renderer through a pipe there instead, so that its bytes reach
us unaltered. A stdin filter becomes a command of our own, since git
only invokes the one named by GIT_PAGER when it talks to a terminal; an
external diff renderer is git's own business either way and needs
nothing but the pipe.

Unix keeps the pty. A renderer reads the width to lay out to off it, so
taking it away would leave every configuration that doesn't name a
width rendering at whatever the renderer falls back to, and diff
renderers have worked on Unix far too long for that.
LAZYGIT_RENDER_WITHOUT_PTY asks for the piped path anyway, which is how
the integration tests cover it on a platform where they run at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Fable 5.1 bf948ba782 Remove the LAZYGIT_COLUMNS environment variable
LAZYGIT_COLUMNS told a diff renderer script the width of the view at a
time when a render on Windows ran without a pty, so that the script had
nowhere else to read it from. It was documented for that case only, and
the documentation went when Windows gained a pty; it has not been
mentioned since.

COLUMNS now tells every command rendering into a view the same thing,
in the variable git and the common renderers already read, and the
{{width}} template variable covers a renderer that reads neither. The
value LAZYGIT_COLUMNS carried was also the width before the layout
pass, which is not always the width the view ends up with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 a51952d826 Lay a diffstat out to the width of the view showing it
Tell a command that renders into a view how wide that view is, through
COLUMNS. git reads it in preference to the size of the terminal it is
talking to, so the diffstat now fills the view whether or not the render
has a terminal to offer.

A diff renderer that can't ask a terminal gets the width from it too;
difftastic, diff-so-fancy and delta all support COLUMNS, so we can stop
running git in a PTY and these will still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 c217a899ee Demonstrate that a diffstat is laid out for 80 columns
git scales the graph of a diffstat to the width of the terminal it is
talking to. A render that talks to no terminal tells it no width, so the
stat comes out narrower than the view it is shown in, wasting most of a
wide one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Fable 5.1 77c9418c89 Rename RunPtyTask to RunDiffRendererTask
The task runs a command with its output shown through the configured
diff renderer. A pty is one way of getting the output to the renderer,
and is about to become one of two, so the task can no longer be named
after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 118d9e8e1f Separate setting up a render from running it in a pty
A render is about to have a second way of giving the diff renderer the
command's output, so the part that decides how it runs needs to be
apart from the part that does.

Move the setting up to newRenderTask, named for what it does now that a
pty is one of two ways of doing it. Leave the pty with the pair of
functions a task drives it by, and with naming the stdin filter to git
as its pager, since git only runs a pager when it talks to a terminal.
What a way needs to know about the render it runs travels as a
renderSpec.

Running the command plainly, with its output going straight into a
pipe, is a way of its own already, and newCmdTask ends as newRenderTask
does, by creating the task that reads the output into the view. Put the
plain way on the same seam as plainRender, and share the ending as
newTaskForRender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 ec3427fdcc Let an external diff command be told the width it renders at
Like a stdin filter, an external diff program lays out its rendering to
the width it reads off the terminal, and a render through a pipe leaves
it nothing to read. difftastic in side-by-side mode is the case that
shows it.

Offer the width as the {{width}} template variable, the same name the
stdin filter command takes it by. Since difftastic supports the COLUMNS
variable, which we will set later in this branch, and I don't know of
any other external-diff renderer that doesn't, we don't document this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 f496f17452 Hand an external diff command to git through the environment
An external diff command rides on the git command as a diff.external
config, which means it is fixed when the command is built. That is
before the layout pass, so the width the renderer is to lay out for
isn't known yet, and the command can't be told about it.

Pass it as GIT_EXTERNAL_DIFF from the render instead, where the width
is known and where a stdin filter is already handed to git the same
way. git ranks the variable exactly as it ranks the config, behind a
per-path diff driver from .gitattributes, so a repository that defines
one still gets it (verified on git 2.22.5 and 2.55). An empty command
means the user wants their own git config to apply, so leave the
variable unset for that; git takes it being set at all as an
instruction.

The renderer command leaves the git arguments, so it also leaves the key
that says which diff a render is of. Cycling between two external diff
renderers now keeps the view's place, the way cycling between two stdin
filters already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 f06b881f55 Let a stdin filter be told the width of the diff it renders
A stdin filter finds out how wide to lay out its rendering by asking the
terminal. That is one of the two jobs the pty around a render does.
Rendering through a pipe instead, as Windows is about to do, leaves the
renderer to pick a width of its own, and a side-by-side rendering comes
out at the wrong size.

Offer the width as the {{width}} template variable, so that a
configuration can name it on the command line where the renderer can no
longer ask for it. {{columnWidth}} is derived from the same number.

This is only needed by diff renderers which don't support the COLUMNS
variable, which we will set later in this branch; delta and
diff-so-fancy both do in their latest versions, so we don't document
this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 dfd6a7dbf2 Add a pipeline whose output can be read as it arrives
Rendering a diff through a renderer on Windows means running the
renderer ourselves, since ConPTY mangles the metadata records it emits
and git only invokes a renderer of its own when it talks to a terminal.
That needs a chain of commands whose output a view can be filled from
while it runs, where PipeCommands runs a chain to completion and reports
what it said afterwards.

StartPipeline starts such a chain and hands back the reader for its
output, along with a handle offering exactly what a render task asks of
a command: something to wait for, something to name it by, and a way to
stop it. Every command's stderr joins the output, so a renderer that
objects to its input says so where the diff would have been.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:59 +02:00
Stefan HallerandClaude Opus 5 a8cd56ba9b Let a pipeline's commands notice when the next one is gone
If a command in a pipeline exits before reading all of its input, the
command feeding it keeps running and PipeCommands never returns.

The parent holds on to the read end of every pipe it wires between two
commands. A pipe with a reader is a pipe worth writing to, so the
command writing into it is never told that nobody is listening.

Close the parent's ends once the commands are running, since each of
them holds its own by then. The write ends have to go as well, or the
command reading a link never reaches the end of its input.

No caller reaches this today. The one chain lazygit pipes is
`git stash show -p` into `git apply -R`, and apply reads its whole input
before it does anything with it, so it never leaves the show writing to
nobody. The chain about to be added for diff renderers is a different
matter: a renderer can fail at any point of its input, and a render that
is no longer wanted is stopped part way through by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:58 +02:00
Stefan HallerandClaude Opus 5 e13c09f961 Extract the reusable parts of a command pipeline
PipeCommands runs a chain of commands to completion and gathers what
they wrote to stderr. Rendering a diff through a renderer needs the same
chain, but has to read the last command's output as it arrives, so it
can't use PipeCommands as it stands.

Pull out what both need: naming the chain for a log, wiring each
command's output to the next one's input, and starting them all with the
cleanup a failure to start requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:31:58 +02:00
131 changed files with 7002 additions and 869 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+142
View File
@@ -0,0 +1,142 @@
// author_colors_repo creates a git repository for checking that the colors
// lazygit gives to authors are readable.
//
// If gui.authorColors names no color for an author, lazygit derives one from a
// hash of their name. The authors of an ordinary repository rarely land near the
// edges of the range that this color is picked from. Every commit in the
// repository created here is by an author at one of those edges: the lowest or
// highest lightness, combined with the lowest or highest saturation, at twelve
// hues around the color wheel. The commit subject says which edge it is.
//
// Usage:
//
// go run ./cmd/author_colors_repo <path>
//
// Then open the repository with lazygit in each terminal theme you want to check.
package main
import (
"fmt"
"log"
"math"
"os"
"os/exec"
"time"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
)
type extreme int
const (
lowest extreme = iota
highest
)
func (self extreme) String() string {
if self == lowest {
return "min"
}
return "max"
}
func (self extreme) matches(fraction float64) bool {
if self == lowest {
return fraction < 0.01
}
return fraction >= 0.99
}
const (
numHues = 12
// Small enough that the windows of neighbouring hues don't overlap
hueTolerance = 0.02
)
type commit struct {
author string
subject string
}
func main() {
if len(os.Args) != 2 {
log.Fatalf("usage: %s <path>", os.Args[0])
}
path := os.Args[1]
if _, err := os.Stat(path); err == nil {
log.Fatalf("%s already exists", path)
}
extremes := []extreme{lowest, highest}
commits := make([]commit, 0, len(extremes)*len(extremes)*numHues)
for _, lightness := range extremes {
for _, saturation := range extremes {
for i := range numHues {
hue := float64(i) / numHues
author, actualHue := findAuthor(lightness, saturation, hue)
subject := fmt.Sprintf("lightness %s, saturation %s, hue %.0f°", lightness, saturation, actualHue*360)
commits = append(commits, commit{author: author, subject: subject})
}
}
}
if err := runGit("", nil, "init", "-q", path); err != nil {
log.Fatal(err)
}
// Commit in reverse, so that the commits panel lists them in the order above
startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range commits {
c := commits[len(commits)-1-i]
date := fmt.Sprintf("%d +0000", startTime.Add(time.Duration(i)*time.Hour).Unix())
env := []string{
"GIT_AUTHOR_NAME=" + c.author,
"GIT_AUTHOR_EMAIL=author@example.com",
"GIT_AUTHOR_DATE=" + date,
"GIT_COMMITTER_NAME=" + c.author,
"GIT_COMMITTER_EMAIL=author@example.com",
"GIT_COMMITTER_DATE=" + date,
}
if err := runGit(path, env, "commit", "-q", "--allow-empty", "-m", c.subject); err != nil {
log.Fatal(err)
}
}
fmt.Printf("Created %s with %d commits\n", path, len(commits))
}
// findAuthor returns the first name of the form "L<lightness> S<saturation> <n>"
// whose color lies at the given extremes of lightness and saturation, and close
// to the given hue. It also returns the hue that the name lands on.
//
// Every name of this form has the same initials, so the color is the only
// thing that differs between authors in the commits panel.
func findAuthor(lightness extreme, saturation extreme, hue float64) (string, float64) {
for n := 1; ; n++ {
name := fmt.Sprintf("L%s S%s %d", lightness, saturation, n)
h, s, l := authors.ColorPosition(name)
if lightness.matches(l) && saturation.matches(s) && hueDistance(h, hue) <= hueTolerance {
return name, h
}
}
}
// hueDistance is the distance between two hues on the color wheel, where each
// hue is a fraction of a full turn.
func hueDistance(a float64, b float64) float64 {
d := math.Abs(a - b)
return math.Min(d, 1-d)
}
func runGit(dir string, env []string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
// Keep the user's git config out of it, so that hooks, commit signing and
// the like don't apply, and every run creates the same commits
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1")
cmd.Env = append(cmd.Env, env...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+88 -23
View File
@@ -37,12 +37,6 @@ This is only meant as a reference for what config options exist, and what their
```yaml
# Config relating to the Lazygit UI
gui:
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
authorColors: {}
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
branchColorPatterns: {}
# Custom icons for filenames and file extensions
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
customIcons:
@@ -169,6 +163,14 @@ gui:
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
shortTimeFormat: 3:04PM
# Whether the terminal has a dark or a light background. This decides whether
# 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to
# stand out against it.
# One of: 'auto' (default) | 'dark' | 'light'
# With 'auto', lazygit asks the terminal, and assumes a dark background if the
# terminal doesn't tell.
colorScheme: auto
# Config relating to colors and styles.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
theme:
@@ -179,7 +181,7 @@ gui:
# Border color of non-focused windows
inactiveBorderColor:
- default
- dim
# Border color of focused window when searching in that window
searchingActiveBorderColor:
@@ -190,14 +192,22 @@ gui:
optionsTextColor:
- blue
# Color and attributes of the text of the selected line. The attributes are
# added to those of the text, and a color replaces the colors of the text.
# Set it to 'default' to leave the text as it is, e.g. if you don't want the
# selected line in bold.
selectedLineFgColor:
- bold
# Background color of selected line.
# Default: 'blue' if the terminal has a dark background, or a suitable RGB blue
# computed from the background color if it is light.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
selectedLineBgColor:
- blue
selectedLineBgColor: []
# Background color of selected line when view doesn't have focus.
inactiveViewSelectedLineBgColor:
- bold
# Default: a suitable RGB grey computed from the terminal's background color.
inactiveViewSelectedLineBgColor: []
# Foreground color of copied commit
cherryPickedCommitFgColor:
@@ -223,6 +233,22 @@ gui:
defaultFgColor:
- default
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
authorColors: {}
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
branchColorPatterns: {}
# Colors and styles that override those in 'theme' when the terminal has a dark
# background. It has the same fields as 'theme'.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
darkTheme: {}
# Colors and styles that override those in 'theme' when the terminal has a light
# background. It has the same fields as 'theme'.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
lightTheme: {}
# Config relating to the commit length indicator
commitLength:
# If true, show an indicator of commit message length
@@ -443,7 +469,9 @@ git:
# If not "none", lazygit will automatically fast-forward local branches to match
# their upstream after fetching. Applies to branches that are not the currently
# checked out branch, and only to those that are strictly behind their upstream
# (as opposed to diverged).
# (as opposed to diverged). A branch that is checked out in another worktree is
# fast-forwarded there, unless that worktree has changes to tracked files or is
# in the middle of a rebase or bisect.
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
autoForwardBranches: onlyMainBranches
@@ -999,6 +1027,7 @@ The available attributes are:
- bold
- default
- dim # faint text; not supported by every terminal
- reverse # useful for high-contrast
- underline
- strikethrough
@@ -1023,28 +1052,61 @@ gui:
- reverse
```
The text of the selected line is bold by default. If you don't want that, set `selectedLineFgColor` to `default`:
```yaml
gui:
theme:
selectedLineFgColor:
- default
```
## Themes for dark and light backgrounds
The colors in `gui.theme` apply whether your terminal has a dark or a light background. If you want different colors for the two, set them in `gui.darkTheme` or `gui.lightTheme`. These have the same fields as `gui.theme`, and a field that you set in them overrides the one in `gui.theme`:
```yaml
gui:
theme:
activeBorderColor:
- green
- bold
lightTheme:
activeBorderColor:
- blue
- bold
```
For `authorColors` and `branchColorPatterns`, each entry overrides the one with the same key in `gui.theme`, and the other entries of `gui.theme` still apply. Branch color patterns of `gui.darkTheme` or `gui.lightTheme` come before those of `gui.theme`.
Lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
## Custom Author Color
Lazygit will assign a random color for every commit author in the commits pane by default.
These colors are picked to be readable against the background of your terminal, and lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
You can customize the color in case you're not happy with the randomly assigned one:
```yaml
gui:
authorColors:
'John Smith': 'red' # use red for John Smith
'Alan Smithee': '#00ff00' # use green for Alan Smithee
theme:
authorColors:
'John Smith': 'red' # use red for John Smith
'Alan Smithee': '#00ff00' # use green for Alan Smithee
```
You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors:
```yaml
gui:
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
theme:
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
```
## Custom Branch Color
@@ -1053,13 +1115,16 @@ You can customize the color of branches based on branch patterns (regular expres
```yaml
gui:
branchColorPatterns:
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
theme:
branchColorPatterns:
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
```
Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed.
If several patterns match a branch, the first one wins.
## Custom Files Icon & Color
You can customize the icon and color of files based on filenames or extensions:
+15 -6
View File
@@ -23,22 +23,29 @@ Fields only for `extDiff`:
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
The `command` of a `stdinFilter` or `extDiff` renderer is a [Go template](https://pkg.go.dev/text/template) with these variables:
- `{{width}}`: the width of the view that the diff is rendered into.
- `{{colorScheme}}`: `dark` or `light`, depending on whether the terminal has a dark or a light background. Lazygit asks the terminal about this; if yours doesn't tell, set `gui.colorScheme`.
- `{{columnWidth}}` (only for `stdinFilter`): the width of one side of a side-by-side rendering, e.g. for `ydiff -p cat -s -w {{columnWidth}}`.
- `{{diffContext}}` (only for `extDiff`): lazygit's current diff context size, the value controlled by the `{`/`}` keybindings.
A variable can also be written with a leading dot, as in `{{.width}}`. The command can use template expressions too; for example, `delta --paging=never {{if gt .width 160}}--side-by-side{{end}}` shows the diff side by side only when there is room for it, or `delta --syntax-theme={{if eq .colorScheme "light"}}Github{{else}}Dracula{{end}}` picks a different syntax theme based on the background.
Here's an example for a multi-renderer setup:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
- command: delta --{{colorScheme}} --paging=never
- command: ydiff -p cat
colorArg: never
- type: extDiff
command: difft --color=always --context={{diffContext}}
command: difft --color=always --background={{colorScheme}} --context={{diffContext}}
- type: rawGit
args: [--color-words]
name: color-words
@@ -51,12 +58,14 @@ git:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
- command: delta --{{colorScheme}} --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
`--{{colorScheme}}` passes `--dark` or `--light` to delta, so that it matches the background of your terminal.
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --{{colorScheme}} --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
+33
View File
@@ -16,3 +16,36 @@ branches properly stacked onto it.
Lazygit visualizes the individual branch heads in the stack by marking them with a
cyan asterisk (or a cyan branch symbol if you are using [nerd
fonts](Config.md#display-nerd-fonts-icons)).
When you push the topmost branch of the stack with `P`, and the branches below
it have commits that haven't been pushed yet, lazygit offers to push them along
with it. After rebasing the stack this saves you from checking out and
force-pushing every branch one by one; you are asked to confirm the force push
once for all of them. Only branches that already have an upstream are included.
Each of them is pushed to where `git push` would push it if it were checked out,
so your push configuration applies to them as usual.
When somebody else rebases the stack and force-pushes it, all your branches
show up as diverged, for example `↓5↑3`, even though the commits they are ahead
by are only the old versions of the ones that are now on the remote. Lazygit
tells this apart from a branch that carries work of your own, and shows the
divergence dimmed for such a branch. Pressing `f` on it resets it to its
upstream instead of refusing, so you don't have to check the branch out and pull
it. Lazygit only does this when every commit of the branch was on its remote
branch at some point. It finds that out from the reflog of the remote-tracking
branch. Reflogs are enabled by default, except in a bare repository; if you work
in one with linked worktrees, set `core.logAllRefUpdates` to true there to make
this work.
`f` works on a [range selection](Range_Select.md) too, so you can select the
whole stack and bring all of it back in sync at once. If any of the selected
branches can't be updated, none of them is, so that you don't end up with half
of the stack updated.
Alternatively, check out the topmost branch of the stack and pull it with `p`.
If branches below it can be updated this way, or are simply behind their
upstream, lazygit offers to update them along with it. Lazygit decides this
from the last fetch, so a branch whose changes on the remote haven't been
fetched yet isn't offered; with auto-fetch turned off, pull a second time after
the first pull has fetched them. Branches that are checked out in another
worktree are left alone. The topmost branch itself is pulled as usual.
+3 -3
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll up main window | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll down main window | |
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
@@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | New tag | |
| `` s `` | Sort order | |
| `` g `` | Reset | |
+3 -3
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | |
| `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
@@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | 삭제 | View delete options for local/remote branch. |
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | 태그를 생성 | |
| `` s `` | Sort order | |
| `` g `` | View reset options | |
+1 -1
View File
@@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Verwijderen | View delete options for local/remote branch. |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | Creëer tag | |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | |
+3 -3
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Прокрутить вверх главную панель | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Прокрутить вниз главную панель | |
| `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
@@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | Создать тег | |
| `` s `` | Порядок сортировки | |
| `` g `` | Просмотреть параметры сброса | |
+1 -1
View File
@@ -145,7 +145,7 @@ func NewGitCommandAux(
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
+95 -4
View File
@@ -285,11 +285,26 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
return self.cmd.New(cmdArgs).Run()
}
// Returns whether refName can be fast-forward merged into the current branch
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
// Fast-forwards the branch that is checked out in the given worktree to the
// given ref. Fails if that can't be done without a merge commit. Pass empty
// strings for the worktree to use the current one.
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
cmdArgs := NewGitCmd("merge").
Arg("--ff-only").
Arg(refName).
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
// Returns whether the first ref is an ancestor of the second one, which also
// means that the second one can be fast-forward merged into the first one
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
cmdArgs := NewGitCmd("merge-base").
Arg("--is-ancestor").
Arg("HEAD", refName).
Arg(ancestorRefName, refName).
ToArgv()
err := self.cmd.New(cmdArgs).DontLog().Run()
return err == nil
@@ -353,9 +368,85 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
return stdout == "", nil
}
func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
// Returns whether the given branch has commits of its own, meaning commits
// that its remote branch never contained. Those are the commits that would be
// lost if we reset the branch to its upstream.
//
// A branch that has diverged from its upstream doesn't necessarily have any
// commits of its own. If somebody else rewrote the remote branch and
// force-pushed it, our branch is still at the commits it had before, and all
// of those were on the remote branch at some point. The reflog of the
// remote-tracking branch records the values it had before it was rewritten, so
// a commit that was ever on the remote branch is contained in one of them.
func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) {
upstreamValues := append(
[]string{branch.FullUpstreamRefName()},
self.previousUpstreamValues(branch.FullUpstreamRefName())...,
)
cmdArgs := NewGitCmd("rev-list").
Arg("--max-count=1").
// A value that the remote-tracking branch had long ago might not be
// available any more, e.g. in a partial clone. Skip it rather than
// failing; it only means we exclude fewer commits.
Arg("--ignore-missing").
Arg(branch.FullRefName()).
Arg(lo.Map(upstreamValues, func(value string, _ int) string {
return "^" + value
})...).
Arg("--").
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return false, err
}
return stdout != "", nil
}
// Returns the values that the given remote-tracking branch had before its
// current one, as far back as its reflog goes. Returns nothing if the reflog
// is unavailable, for example because core.logAllRefUpdates is false; a branch
// that is strictly behind its upstream is recognized without it.
func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string {
cmdArgs := NewGitCmd("reflog").
Arg("show").
Arg("--format=%H").
Arg(upstreamRef).
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return nil
}
// Each entry holds the value that the ref was updated to.
values := utils.SplitLines(stdout)
// The value it had before the oldest entry is that entry's old value, and
// the only way to name it is <ref>@{<number of entries>}. It doesn't exist
// if the oldest entry is the one that created the ref, and asking for it
// then is an error rather than an empty result.
cmdArgs = NewGitCmd("rev-parse").
Arg("-q", "--verify").
Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))).
ToArgv()
if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil {
values = append(values, strings.TrimSpace(stdout))
}
return values
}
// Moves branches by writing refs directly. The reflog message is what
// `git reflog <branch>` shows for the update; it is the only hint about who
// moved the branch, as no git command shows up in the reflog for this.
func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error {
cmdArgs := NewGitCmd("update-ref").
Arg("--stdin").
Arg("-m", reflogMessage).
ToArgv()
return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
+74 -8
View File
@@ -44,6 +44,7 @@ type BranchLoader struct {
*GitCommon
cmd oscommands.ICmdObjBuilder
getCurrentBranchInfo func() (BranchInfo, error)
hasLocalOnlyCommits func(*models.Branch) (bool, error)
config BranchLoaderConfigCommands
}
@@ -52,6 +53,7 @@ func NewBranchLoader(
gitCommon *GitCommon,
cmd oscommands.ICmdObjBuilder,
getCurrentBranchInfo func() (BranchInfo, error),
hasLocalOnlyCommits func(*models.Branch) (bool, error),
config BranchLoaderConfigCommands,
) *BranchLoader {
return &BranchLoader{
@@ -59,6 +61,7 @@ func NewBranchLoader(
GitCommon: gitCommon,
cmd: cmd,
getCurrentBranchInfo: getCurrentBranchInfo,
hasLocalOnlyCommits: hasLocalOnlyCommits,
config: config,
}
}
@@ -67,7 +70,7 @@ func NewBranchLoader(
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
mainBranches *MainBranches,
oldBranches []*models.Branch,
loadBehindCounts bool,
loadExtraInfo bool,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
@@ -135,24 +138,63 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
branch.UpstreamBranch = match.Merge
}
// If the branch already existed, take over its BehindBaseBranch value
// to reduce flicker
// If the branch already existed, take over the values that are
// determined in the background, to reduce flicker
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
return b.Name == branch.Name
}); found {
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load())
}
}
if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
if loadExtraInfo {
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
return self.checkForRewrittenUpstreams(branches, renderFunc)
})
}
return branches, nil
}
// For each branch that has diverged from its upstream, determines whether the
// divergence comes from the upstream branch having been rewritten, and stores
// the answer in the branch. A branch that we can't determine it for keeps the
// answer "no", so that we don't offer anything we aren't sure about.
func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error {
t := time.Now()
errg := errgroup.Group{}
for _, branch := range branches {
if !branch.IsAheadForPull() || !branch.IsBehindForPull() {
branch.UpstreamRewritten.Store(false)
continue
}
errg.Go(func() error {
hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch)
if err != nil {
// Not worth bothering the user about; it only means that we
// don't show this branch differently.
self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err)
}
branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits)
return nil
})
}
err := errg.Wait()
self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t))
renderFunc()
return err
}
func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
branches []*models.Branch,
mainBranches *MainBranches,
@@ -349,6 +391,7 @@ var branchFields = []string{
"upstream:short",
"upstream:track",
"push:track",
"push",
"subject",
"objectname",
"committerdate:unix",
@@ -361,13 +404,15 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
upstreamName := split[2]
track := split[3]
pushTrack := split[4]
subject := split[5]
commitHash := split[6]
commitDate := split[7]
pushRef := split[5]
subject := split[6]
commitHash := split[7]
commitDate := split[8]
name := strings.TrimPrefix(fullName, "heads/")
aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)
pushRemote, pushBranch := parsePushDestination(pushRef)
recency := ""
if storeCommitDateAsRecency {
@@ -383,6 +428,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
BehindForPull: behindForPull,
AheadForPush: aheadForPush,
BehindForPush: behindForPush,
PushRemote: pushRemote,
PushBranch: pushBranch,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
@@ -410,6 +457,25 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool)
return ahead, behind, false
}
// Splits the remote-tracking ref that the %(push) field names, e.g.
// refs/remotes/origin/main, into the remote and the remote branch. Returns
// empty strings if the field is empty because git has no push destination for
// the branch, or if the ref isn't under refs/remotes/.
func parsePushDestination(pushRef string) (string, string) {
remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/")
if !ok {
return "", ""
}
// Remote names can't contain slashes, so the first one ends the remote name
remote, branch, ok := strings.Cut(remoteAndBranch, "/")
if !ok {
return "", ""
}
return remote, branch
}
func parseDifference(track string, regexStr string) string {
re := regexp.MustCompile(regexStr)
match := re.FindStringSubmatch(track)
+103 -6
View File
@@ -6,8 +6,10 @@ import (
"testing"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/sasha-s/go-deadlock"
"github.com/stretchr/testify/assert"
)
@@ -26,7 +28,7 @@ func TestObtainBranch(t *testing.T) {
scenarios := []scenario{
{
testName: "TrimHeads",
input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp},
input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -41,7 +43,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "NoUpstream",
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -56,7 +58,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "IsHead",
input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp},
input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -71,7 +73,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "IsBehindAndAhead",
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -79,6 +81,40 @@ func TestObtainBranch(t *testing.T) {
BehindForPull: "2",
AheadForPush: "3",
BehindForPush: "2",
PushRemote: "a_remote",
PushBranch: "a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
},
},
{
testName: "PushDestinationDiffersFromUpstream",
input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
AheadForPull: "3",
BehindForPull: "0",
AheadForPush: "5",
BehindForPush: "0",
PushRemote: "my_fork",
PushBranch: "feature/a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
},
},
{
testName: "PushDestinationNotARemoteTrackingRef",
input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
AheadForPull: "0",
BehindForPull: "0",
AheadForPush: "0",
BehindForPush: "0",
Head: false,
Subject: "subject",
CommitHash: "123",
@@ -86,7 +122,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "RemoteBranchIsGone",
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -95,6 +131,8 @@ func TestObtainBranch(t *testing.T) {
BehindForPull: "?",
AheadForPush: "?",
BehindForPush: "?",
PushRemote: "a_remote",
PushBranch: "a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
@@ -102,7 +140,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "WithCommitDateAsRecency",
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: true,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -255,3 +293,62 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
runner.CheckForMissingCalls()
}
func TestCheckForRewrittenUpstreams(t *testing.T) {
branch := func(name string, ahead string, behind string) *models.Branch {
return &models.Branch{
Name: name,
UpstreamRemote: "origin",
UpstreamBranch: name,
AheadForPull: ahead,
BehindForPull: behind,
}
}
notDiverged := branch("not-diverged", "0", "2")
rewritten := branch("rewritten", "3", "5")
ownCommits := branch("own-commits", "3", "5")
failing := branch("failing", "1", "1")
// A branch that is no longer diverged must lose the value it had before
notDiverged.UpstreamRewritten.Store(true)
branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing}
var mutex deadlock.Mutex
queried := []string{}
hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) {
mutex.Lock()
queried = append(queried, branch.Name)
mutex.Unlock()
switch branch.Name {
case "own-commits":
return true, nil
case "failing":
return false, errors.New("error")
default:
return false, nil
}
}
gitCommon := buildGitCommon(commonDeps{})
loader := &BranchLoader{
Common: gitCommon.Common,
GitCommon: gitCommon,
cmd: gitCommon.cmd,
hasLocalOnlyCommits: hasLocalOnlyCommits,
}
rendered := false
err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true })
assert.NoError(t, err)
assert.True(t, rendered, "renderFunc should have been called")
assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried,
"only diverged branches should be looked at")
assert.False(t, notDiverged.UpstreamRewritten.Load())
assert.True(t, rewritten.UpstreamRewritten.Load())
assert.False(t, ownCommits.UpstreamRewritten.Load())
assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything")
}
+109
View File
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/stretchr/testify/assert"
@@ -317,3 +318,111 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
})
}
}
func TestBranchHasLocalOnlyCommits(t *testing.T) {
type scenario struct {
testName string
runner *oscommands.FakeCmdObjRunner
test func(bool, error)
}
branch := &models.Branch{
Name: "branch",
UpstreamRemote: "origin",
UpstreamBranch: "branch",
}
scenarios := []scenario{
{
"branch is strictly behind its upstream, and there are no reflogs",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch", "--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the upstream branch was rewritten, so all our commits were on it before",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"},
"3333333333333333333333333333333333333333\n", nil).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"^2222222222222222222222222222222222222222",
"^3333333333333333333333333333333333333333",
"--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the oldest reflog entry is the one that created the ref",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the branch has a commit that was never on the upstream branch",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"},
"2222222222222222222222222222222222222222\n", nil).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"^2222222222222222222222222222222222222222",
"--",
}, "4444444444444444444444444444444444444444\n", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.True(t, hasLocalOnlyCommits)
},
},
{
"bubbles up an error from rev-list",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch", "--",
}, "", errors.New("error")),
func(hasLocalOnlyCommits bool, err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
instance := buildBranchCommands(commonDeps{runner: s.runner})
s.test(instance.HasLocalOnlyCommits(branch))
s.runner.CheckForMissingCalls()
})
}
}
+1 -1
View File
@@ -312,7 +312,7 @@ func TestCommitShowCmdObj(t *testing.T) {
similarityThreshold: 50,
ignoreWhitespace: false,
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Show diff using git's external diff config",
@@ -125,11 +125,9 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
return self.
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
+3 -2
View File
@@ -59,9 +59,10 @@ func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName strin
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
}
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error {
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagNames []string) error {
cmdArgs := NewGitCmd("push").
Arg(remoteName, "--delete", "refs/tags/"+tagName).
Arg(remoteName, "--delete").
Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+1 -1
View File
@@ -139,7 +139,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
similarityThreshold: 50,
ignoreWhitespace: false,
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Show diff using git's external diff config",
+25 -11
View File
@@ -6,6 +6,7 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
type SyncCommands struct {
@@ -18,18 +19,21 @@ func NewSyncCommands(gitCommon *GitCommon) *SyncCommands {
}
}
// Push pushes to a branch
type PushOpts struct {
Force bool
ForceWithLease bool
CurrentBranch string
UpstreamRemote string
UpstreamBranch string
SetUpstream bool
// The remote to push to. If empty, git picks it from its configuration,
// and Refspecs must be empty too.
Remote string
// What to push, each in the form "refs/heads/<local branch>:<remote ref>".
// If empty, git decides what to push based on push.default and
// remote.<name>.push.
Refspecs []string
}
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) {
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
if len(opts.Refspecs) > 0 && opts.Remote == "" {
return nil, errors.New(self.Tr.MustSpecifyOriginError)
}
@@ -37,8 +41,8 @@ func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommand
ArgIf(opts.Force, "--force").
ArgIf(opts.ForceWithLease, "--force-with-lease").
ArgIf(opts.SetUpstream, "--set-upstream").
ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote).
ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)).
ArgIf(opts.Remote != "", opts.Remote).
Arg(opts.Refspecs...).
ToArgv()
cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task)
@@ -110,15 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
}
func (self *SyncCommands) FastForward(
// Fetches the given branches of the given remote, updating their
// remote-tracking branches. Local branches are left alone, including the ones
// that track them.
func (self *SyncCommands) FetchRemoteBranches(
task gocui.Task,
branchName string,
remoteName string,
remoteBranchName string,
remoteBranchNames []string,
) error {
// The explicit destinations and the leading + make sure that the
// remote-tracking branches are updated even when the remote branches were
// rewritten, whatever the remote's fetch refspec says
refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string {
return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
remoteBranchName, remoteName, remoteBranchName)
})
cmdArgs := self.fetchCommandBuilder(false).
Arg(remoteName).
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
Arg(refspecs...).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+22 -13
View File
@@ -41,12 +41,11 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push with force disabled, upstream supplied",
testName: "Push with force disabled, refspec supplied",
opts: PushOpts{
ForceWithLease: false,
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
},
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
@@ -57,9 +56,8 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force disabled, setting upstream",
opts: PushOpts{
ForceWithLease: false,
CurrentBranch: "master-local",
UpstreamRemote: "origin",
UpstreamBranch: "master",
Remote: "origin",
Refspecs: []string{"refs/heads/master-local:master"},
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -71,9 +69,8 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force-with-lease enabled, setting upstream",
opts: PushOpts{
ForceWithLease: true,
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -82,11 +79,23 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push with remote branch but no origin",
testName: "Push several refspecs",
opts: PushOpts{
ForceWithLease: true,
UpstreamRemote: "",
UpstreamBranch: "master",
Remote: "origin",
Refspecs: []string{"refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"},
},
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "origin", "refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"})
assert.NoError(t, err)
},
},
{
testName: "Push with refspec but no remote",
opts: PushOpts{
ForceWithLease: true,
Remote: "",
Refspecs: []string{"refs/heads/master:master"},
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
+6 -4
View File
@@ -5,6 +5,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
type TagCommands struct {
@@ -46,15 +47,16 @@ func (self *TagCommands) HasTag(tagName string) bool {
return self.cmd.New(cmdArgs).DontLog().Run() == nil
}
func (self *TagCommands) LocalDelete(tagName string) error {
cmdArgs := NewGitCmd("tag").Arg("-d", tagName).
func (self *TagCommands) LocalDelete(tagNames []string) error {
cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error {
cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName).
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagNames []string) error {
cmdArgs := NewGitCmd("push").Arg(remoteName).
Arg(lo.FlatMap(tagNames, func(t string, _ int) []string { return []string{"tag", t} })...).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+36
View File
@@ -524,6 +524,42 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error {
return self.cmd.New(cmdArgs).Run()
}
// ResetKeep runs `git reset --keep` in the given worktree, which moves the
// checked out branch to the given ref while keeping local modifications. It
// fails rather than overwriting a file that differs between the two commits.
// Pass empty strings for the worktree to use the current one.
func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error {
cmdArgs := NewGitCmd("reset").Arg("--keep", ref).
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
// Returns whether the given worktree has changes to tracked files, either in
// its working tree or in its index. Untracked files don't count, and neither do
// submodules. A submodule that is checked out at a different commit than the
// one recorded, or that has changes of its own, doesn't get in the way of
// moving the branch, because moving it leaves the submodules alone. Pass empty
// strings for the worktree to use the current one.
func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) {
cmdArgs := NewGitCmd("status").
Arg("--porcelain").
Arg("--untracked-files=no").
Arg("--ignore-submodules").
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return false, err
}
return stdout != "", nil
}
func (self *WorkingTreeCommands) ResetMixed(ref string) error {
cmdArgs := NewGitCmd("reset").Arg("--mixed", ref).
ToArgv()
@@ -140,12 +140,14 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
rebasedBranch, ok := self.rebasedBranch(worktree)
if ok {
worktree.Branch = rebasedBranch
worktree.IsRebasingOrBisecting = true
continue
}
bisectedBranch, ok := self.bisectedBranch(worktree)
if ok {
worktree.Branch = bisectedBranch
worktree.IsRebasingOrBisecting = true
continue
}
}
+21
View File
@@ -31,6 +31,13 @@ type Branch struct {
// 'git@github.com:tiwood/lazygit.git'
UpstreamRemote string
UpstreamBranch string
// The remote and the remote branch that `git push` would push this branch
// to, as git determines them from push.default, remote.pushDefault and
// branch.<name>.pushRemote. In a triangular workflow these differ from the
// upstream. Both are empty if git has no push destination for the branch,
// e.g. because push.default is "upstream" and the branch has no upstream.
PushRemote string
PushBranch string
// subject line in commit message
Subject string
// commit hash
@@ -40,6 +47,13 @@ type Branch struct {
// determined yet, or up to date with base branch. (We don't need to
// distinguish the two, as we don't draw anything in both cases.)
BehindBaseBranch atomic.Int32
// Whether the branch has diverged from its upstream because the upstream
// branch was rewritten, and not because the branch has commits of its own.
// Such a branch can be reset to its upstream without losing anything.
// False for branches that haven't diverged, and for those we haven't
// determined it for yet.
UpstreamRewritten atomic.Bool
}
func (b *Branch) FullRefName() string {
@@ -119,6 +133,13 @@ func (b *Branch) IsBehindForPush() bool {
return b.RemoteBranchStoredLocally() && b.BehindForPush != "0"
}
// Whether the branch has commits that its push destination doesn't have. False
// if the remote branch it would be pushed to isn't stored locally, in which
// case the count is "?".
func (b *Branch) IsAheadForPush() bool {
return b.RemoteBranchStoredLocally() && b.AheadForPush != "0" && b.AheadForPush != "?"
}
// for when we're in a detached head state
func (b *Branch) IsRealBranch() bool {
return b.AheadForPull != "" && b.BehindForPull != ""
+3
View File
@@ -19,6 +19,9 @@ type Worktree struct {
// * the worktree is mid-rebase on the branch
// * the worktree is mid-bisect on the branch
Branch string
// If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD
// is detached rather than pointing at the branch
IsRebasingOrBisecting bool
// The HEAD sha of the worktree. Always populated (even when Branch is set).
// Used for display when Branch is empty (detached HEAD state).
Head string
+8
View File
@@ -108,6 +108,14 @@ func (self *CmdObj) GetEnvVars() []string {
return self.cmd.Env
}
// SetEnviron replaces the command's whole environment, for a command that has
// to run in the same one as another command rather than in this process's.
func (self *CmdObj) SetEnviron(env []string) *CmdObj {
self.cmd.Env = env
return self
}
// sets the working directory
func (self *CmdObj) SetWd(wd string) *CmdObj {
self.cmd.Dir = wd
@@ -33,6 +33,16 @@ func (self *CmdObjBuilder) New(args []string) *CmdObj {
return cmdObj
}
// NewFromCmd wraps a command that has already been built, for a caller that
// holds an *exec.Cmd and needs it as a CmdObj. The command itself is shared,
// not copied, so whatever was set on it still applies.
func (self *CmdObjBuilder) NewFromCmd(cmd *exec.Cmd) *CmdObj {
return &CmdObj{
cmd: cmd,
runner: self.runner,
}
}
// A command with explicit environment from env
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
cmd := exec.Command(args[0], args[1:]...)
+5 -43
View File
@@ -4,12 +4,10 @@ import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/samber/lo"
"github.com/atotto/clipboard"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -203,61 +201,25 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
c.logPipeline(cmdObjs)
logCmdStr := strings.Join(
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
return cmdObj.ToString()
}),
" | ",
)
c.LogCommand(logCmdStr, true)
for i := range len(cmds) - 1 {
stdout, err := cmds[i].StdoutPipe()
if err != nil {
return err
}
cmds[i+1].Stdin = stdout
cmds, parentEnds, err := wirePipeline(cmdObjs)
if err != nil {
return err
}
// keeping this here in case I adapt this code for some other purpose in the future
// cmds[len(cmds)-1].Stdout = os.Stdout
stderrs := make([]bytes.Buffer, len(cmds))
for i := range cmds {
cmds[i].Stderr = &stderrs[i]
}
// Start every command before waiting for any of them: waiting for a command
// closes our end of the pipe that feeds the next one, and a command that
// hasn't been started by then would inherit a closed stdin.
started := 0
var startErr error
for _, cmd := range cmds {
if err := cmd.Start(); err != nil {
startErr = err
break
}
started++
}
started, startErr := startPipeline(cmds, parentEnds)
finalErrors := []string{}
if startErr != nil {
c.Log.Error(startErr)
finalErrors = append(finalErrors, startErr.Error())
// Without the rest of the pipeline to drain them, the commands we did
// start could block forever writing to a full pipe.
for _, cmd := range cmds[:started] {
_ = cmd.Process.Kill()
}
}
for i, cmd := range cmds[:started] {
+180
View File
@@ -0,0 +1,180 @@
package oscommands
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/samber/lo"
)
// Pipeline is a chain of running commands, each one's output feeding the next
// one's input, with the last one's output going somewhere the caller reads. Its
// method set is the one a render task expects of a command (see tasks.Cmd), so
// a pipeline can render a view just as a single command can.
type Pipeline struct {
cmds []*exec.Cmd
cmdStr string
}
// StartPipeline starts the given commands wired A | B | C and returns the
// pipeline together with the reader for its output.
//
// Every command's stderr goes to that same output, so whatever a command
// complains about is part of what the caller reads. A diff renderer's error
// message belongs on screen with the diff it failed to render.
//
// Closing the reader is how a pipeline is brought down. The last command's next
// write fails, so it exits, and the failure travels back up the chain as each
// command in turn writes into a pipe whose reader is gone.
func (c *OSCommand) StartPipeline(cmdObjs ...*CmdObj) (*Pipeline, io.ReadCloser, error) {
c.logPipeline(cmdObjs)
cmds, parentEnds, err := wirePipeline(cmdObjs)
if err != nil {
return nil, nil, err
}
reader, writer, err := os.Pipe()
if err != nil {
closeAll(parentEnds)
return nil, nil, err
}
for _, cmd := range cmds {
cmd.Stderr = writer
}
cmds[len(cmds)-1].Stdout = writer
parentEnds = append(parentEnds, writer)
started, err := startPipeline(cmds, parentEnds)
if err != nil {
for _, cmd := range cmds[:started] {
_ = cmd.Wait()
}
_ = reader.Close()
return nil, nil, err
}
return &Pipeline{cmds: cmds, cmdStr: pipelineString(cmdObjs)}, reader, nil
}
func (self *Pipeline) String() string {
return self.cmdStr
}
// Wait waits for every command to exit and reports the failure nearest the end
// of the pipeline. A command that fails leaves the ones before it writing into
// a pipe nobody reads, so their own broken-pipe failures are consequences of it
// rather than the cause worth reporting, while a command that fails early
// leaves the ones after it with nothing to read and no reason to fail at all.
func (self *Pipeline) Wait() error {
var lastErr error
for _, cmd := range self.cmds {
if err := cmd.Wait(); err != nil {
lastErr = fmt.Errorf("%s: %w", cmd.String(), err)
}
}
return lastErr
}
// Terminate asks every command to stop, without waiting for any of them. On
// platforms where that does nothing, the pipeline comes down when its output
// reader is closed; see StartPipeline.
func (self *Pipeline) Terminate() error {
var firstErr error
for _, cmd := range self.cmds {
if err := TerminateProcessGracefully(cmd.Process); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// logPipeline enters a chain of commands into the command log, unless the first
// command was marked not to be logged; it speaks for the pipeline. A render runs
// its pipeline again on every selection change, so a caller has to be able to
// keep it out of the log.
func (c *OSCommand) logPipeline(cmdObjs []*CmdObj) {
if cmdObjs[0].ShouldLog() {
c.LogCommand(pipelineString(cmdObjs), true)
}
}
// pipelineString names a chain of commands the way a shell would write it.
func pipelineString(cmdObjs []*CmdObj) string {
return strings.Join(
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
return cmdObj.ToString()
}),
" | ",
)
}
// wirePipeline connects each command's output to the next one's input, like
// A | B | C, and returns the commands along with the parent's ends of those
// pipes. The last command's output is left for the caller to direct.
//
// The parent's ends have to be closed once the commands are running.
// startPipeline does that; see there for why it matters.
func wirePipeline(cmdObjs []*CmdObj) ([]*exec.Cmd, []io.Closer, error) {
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
parentEnds := []io.Closer{}
for i := range len(cmds) - 1 {
reader, writer, err := os.Pipe()
if err != nil {
closeAll(parentEnds)
return nil, nil, err
}
cmds[i].Stdout = writer
cmds[i+1].Stdin = reader
parentEnds = append(parentEnds, reader, writer)
}
return cmds, parentEnds, nil
}
// startPipeline starts every command and reports how many it got going. Every
// one is started before any of them is waited for: waiting closes our end of
// the pipe that feeds the next command, and one that hasn't been started by
// then would inherit a closed stdin.
//
// Once they are all running, each of them holds its own ends of the pipes it
// reads and writes, and the parent lets go of its copies. Both directions
// matter. While the parent holds the read end of a link, a command writing
// into it never learns that the command meant to read it is gone, and keeps
// running after the pipeline has been brought down. While the parent holds the
// write end, the command reading it never reaches the end of its input.
//
// When a command fails to start, the ones already running are killed, since
// without the rest of the pipeline to drain them they could block forever
// writing to a full pipe. They still have to be reaped, so the count covers
// them too.
func startPipeline(cmds []*exec.Cmd, parentEnds []io.Closer) (int, error) {
defer closeAll(parentEnds)
for i, cmd := range cmds {
if err := cmd.Start(); err != nil {
for _, started := range cmds[:i] {
_ = started.Process.Kill()
}
return i, err
}
}
return len(cmds), nil
}
func closeAll(closers []io.Closer) {
for _, closer := range closers {
_ = closer.Close()
}
}
+150
View File
@@ -0,0 +1,150 @@
package oscommands
import (
"fmt"
"io"
"os"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// The pipeline tests need programs to run, and the test binary is the one
// program every platform we test on is sure to have. pipelineMember builds a
// command that re-runs this binary in the role a member of the pipeline is to
// play; the roles are in TestPipelineMember.
const pipelineRoleEnvVar = "LAZYGIT_TEST_PIPELINE_ROLE"
func pipelineMember(role string) *CmdObj {
return NewDummyOSCommand().Cmd.
New([]string{os.Args[0], "-test.run=^TestPipelineMember$"}).
AddEnvVars(pipelineRoleEnvVar + "=" + role)
}
// TestPipelineMember is the program the pipeline tests run, not a test of its
// own. It exits before the testing package reports anything, so that its output
// is what the role wrote and nothing else.
//
// For the same reason it exits with syscall.Exit, which skips the exit hooks
// that os.Exit runs. In a binary built with -cover, one of these hooks writes
// coverage data to $GOCOVERDIR and prints an error to stderr if that fails. On
// Windows it fails now and then if two members of a pipeline exit at the same
// time, because both of them replace the same file in that directory.
func TestPipelineMember(t *testing.T) {
switch os.Getenv(pipelineRoleEnvVar) {
case "":
t.Skip("not a test; the pipeline tests run this binary in a role")
case "count":
for i := 1; i <= 3; i++ {
fmt.Printf("line %d\n", i)
}
case "upcase":
input, _ := io.ReadAll(os.Stdin)
fmt.Print(strings.ToUpper(string(input)))
case "copy":
_, _ = io.Copy(os.Stdout, os.Stdin)
case "complain":
fmt.Fprintln(os.Stderr, "something went wrong")
syscall.Exit(3)
case "flood":
// A failed write means the reader is gone, and there is no point
// writing to nobody. On platforms that raise a signal for it instead,
// this process is already dead by the time the write returns.
for i := 1; ; i++ {
if _, err := fmt.Printf("line %d\n", i); err != nil {
break
}
}
}
syscall.Exit(0)
}
func TestStartPipelineStreamsTheOutputOfTheLastCommand(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("count"),
pipelineMember("upcase"),
)
assert.NoError(t, err)
output, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "LINE 1\nLINE 2\nLINE 3\n", string(output))
assert.NoError(t, pipeline.Wait())
assert.NoError(t, reader.Close())
}
func TestStartPipelineReadsWhatTheCommandsComplainAbout(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("count"),
pipelineMember("complain"),
)
assert.NoError(t, err)
output, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "something went wrong\n", string(output))
// The failure of the command nearest the output is the one reported, even
// though the one feeding it was left writing into a pipe nobody reads.
assert.ErrorContains(t, pipeline.Wait(), "exit status 3")
assert.NoError(t, reader.Close())
}
func TestClosingAPipelinesOutputBringsItDown(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("flood"),
pipelineMember("copy"),
)
assert.NoError(t, err)
// Read some output first, so that both commands are past their startup and
// really running when the reader goes.
buf := make([]byte, len("line 1\n"))
_, err = io.ReadFull(reader, buf)
assert.NoError(t, err)
assert.Equal(t, "line 1\n", string(buf))
assert.NoError(t, reader.Close())
done := make(chan error, 1)
go func() { done <- pipeline.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the pipeline was still running long after its output was closed")
}
}
func TestStartPipelineReportsACommandItCannotStart(t *testing.T) {
osCommand := NewDummyOSCommand()
_, _, err := osCommand.StartPipeline(
pipelineMember("count"),
osCommand.Cmd.New([]string{"lazygit-no-such-command"}),
)
assert.Error(t, err)
}
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- NewDummyOSCommand().PipeCommands(
pipelineMember("flood"),
pipelineMember("complain"),
)
}()
select {
case err := <-done:
assert.ErrorContains(t, err, "something went wrong")
case <-time.After(10 * time.Second):
t.Fatal("PipeCommands was still waiting for a command whose output nothing reads")
}
}
+1 -3
View File
@@ -144,9 +144,7 @@ func TerminateLivePtys() {
// graceful signal worth waiting on — git and the common diff tools leave it
// to the default handler, which calls ExitProcess at whatever instruction
// the process happens to execute — so clients that got the event are
// already dying. Killing at an arbitrary point cannot leak a stale
// index.lock, because pty-rendered commands don't take that lock (see
// withPtyGitConfig in pkg/gui/pty.go).
// already dying.
//
// The pseudoconsole close gets its own goroutine because the kill must not
// wait for it: on builds where ClosePseudoConsole blocks until the console
+53
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
@@ -309,6 +310,13 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
}
}
// This creates gui.branchColorPatterns, so it must run before the move of
// that key into gui.theme below.
err = migrateBranchColors(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
}
pathsToMove := []struct {
oldPath []string
newPath []string
@@ -317,6 +325,14 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
[]string{"keybinding", "worktrees", "viewWorktreeOptions"},
[]string{"keybinding", "universal", "newWorktree"},
},
{
[]string{"gui", "authorColors"},
[]string{"gui", "theme", "authorColors"},
},
{
[]string{"gui", "branchColorPatterns"},
[]string{"gui", "theme", "branchColorPatterns"},
},
}
for _, pathToMove := range pathsToMove {
@@ -630,6 +646,43 @@ func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) erro
})
}
// The deprecated gui.branchColors matched its keys against the part of a branch
// name before the first slash. Turn each key into a pattern that matches the
// same branches. If the file has a non-empty gui.branchColorPatterns,
// gui.branchColors was ignored, so remove it.
func migrateBranchColors(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"gui"}, func(guiNode *yaml.Node) error {
branchColorsKeyNode, branchColorsValueNode := yaml_utils.LookupKey(guiNode, "branchColors")
if branchColorsKeyNode == nil || branchColorsValueNode.Kind != yaml.MappingNode {
return nil
}
patternsKeyNode, patternsValueNode := yaml_utils.LookupKey(guiNode, "branchColorPatterns")
if patternsKeyNode != nil {
switch {
case patternsValueNode.Kind == yaml.MappingNode && len(patternsValueNode.Content) > 0:
yaml_utils.RemoveKey(guiNode, "branchColors")
changes.Add("Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set")
return nil
case patternsValueNode.Kind == yaml.MappingNode || patternsValueNode.Tag == "!!null":
yaml_utils.RemoveKey(guiNode, "branchColorPatterns")
default:
return nil
}
}
branchColorsKeyNode.Value = "branchColorPatterns"
for i := 0; i < len(branchColorsValueNode.Content)-1; i += 2 {
keyNode := branchColorsValueNode.Content[i]
keyNode.Value = "^" + regexp.QuoteMeta(keyNode.Value) + "(/|$)"
keyNode.Tag = "!!str"
}
changes.Add("Converted 'gui.branchColors' to 'gui.branchColorPatterns'")
return nil
})
}
func hasNonNullScalarValue(node *yaml.Node) bool {
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
}
+142
View File
@@ -175,6 +175,32 @@ func TestMigrationOfMovedKeys(t *testing.T) {
expectedDidChange: true,
expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"},
},
{
name: "Move author and branch colors into the theme",
input: `gui:
authorColors:
John Smith: red
theme:
activeBorderColor:
- green
branchColorPatterns:
^docs/: blue
`,
expected: `gui:
theme:
activeBorderColor:
- green
authorColors:
John Smith: red
branchColorPatterns:
^docs/: blue
`,
expectedDidChange: true,
expectedChanges: []string{
"Moved 'gui.authorColors' to 'gui.theme.authorColors'",
"Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'",
},
},
}
for _, s := range scenarios {
@@ -821,3 +847,119 @@ func TestPagerMigration(t *testing.T) {
})
}
}
func TestBranchColorsMigration(t *testing.T) {
moved := "Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'"
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "No branchColors",
input: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "branchColors is not an object",
input: "gui:\n" +
" branchColors: 5\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "branchColors is converted to patterns",
input: "gui:\n" +
" scrollHeight: 2\n" +
" branchColors:\n" +
" feature: green\n" +
" v1.x: '#ff0000'\n" +
" 123: red\n" +
" mouseEvents: false\n",
expected: "gui:\n" +
" scrollHeight: 2\n" +
" mouseEvents: false\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n" +
" ^v1\\.x(/|$): '#ff0000'\n" +
" ^123(/|$): red\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors is removed if branchColorPatterns is set",
input: "gui:\n" +
" branchColors:\n" +
" feature: green\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expectedDidChange: true,
expectedChanges: []string{"Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set", moved},
},
{
name: "branchColors replaces an empty branchColorPatterns",
input: "gui:\n" +
" branchColorPatterns: {}\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors replaces a null branchColorPatterns",
input: "gui:\n" +
" branchColorPatterns:\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors is kept if branchColorPatterns is not an object",
input: "gui:\n" +
" branchColorPatterns: 5\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" branchColors:\n" +
" feature: green\n" +
" theme:\n" +
" branchColorPatterns: 5\n",
expectedDidChange: true,
expectedChanges: []string{moved},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
changes := NewChangesSet()
actual, didChange, err := computeMigratedConfig("path doesn't matter", []byte(s.input), changes)
assert.NoError(t, err)
assert.Equal(t, s.expectedDidChange, didChange)
if didChange {
assert.Equal(t, s.expected, string(actual))
}
assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest())
})
}
}
+69
View File
@@ -0,0 +1,69 @@
package config
import (
"slices"
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
// ColorPatterns assigns colors to the names that match regular expressions.
// It's written in YAML as a mapping from pattern to color, and keeps the
// patterns in the order in which they are written, so that the first pattern
// that matches a name can decide its color.
type ColorPatterns []ColorPattern
type ColorPattern struct {
Pattern string
Color string
}
// UnmarshalYAML puts the patterns it reads in front of the ones that are there
// already, which come from config files that were loaded earlier.
func (p *ColorPatterns) UnmarshalYAML(node *yaml.Node) error {
// Decoding into a map reports malformed input the same way as for the
// other maps in the config.
var colors map[string]string
if err := node.Decode(&colors); err != nil {
return err
}
patterns := make(ColorPatterns, 0, len(colors))
for i := 0; i < len(node.Content)-1; i += 2 {
var pattern string
if err := node.Content[i].Decode(&pattern); err != nil {
return err
}
patterns = append(patterns, ColorPattern{Pattern: pattern, Color: colors[pattern]})
}
*p = patterns.over(*p)
return nil
}
func (p ColorPatterns) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
for _, pattern := range p {
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Pattern},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Color},
)
}
return node, nil
}
// JSONSchema describes the patterns as the mapping they are written as.
func (ColorPatterns) JSONSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Type: "string"},
}
}
// over returns p followed by the patterns of lower that p doesn't have.
func (p ColorPatterns) over(lower ColorPatterns) ColorPatterns {
return slices.Concat(p, lo.Reject(lower, func(l ColorPattern, _ int) bool {
return slices.ContainsFunc(p, func(c ColorPattern) bool { return c.Pattern == l.Pattern })
}))
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
type colorPatternsConfig struct {
Patterns ColorPatterns `yaml:"patterns"`
}
func TestColorPatternsKeepTheirOrder(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^b': red\n"+
" '^a': '#00ff00'\n"+
" '^c': blue\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "^c", Color: "blue"},
}, config.Patterns)
}
func TestColorPatternsOfALaterFileComeFirst(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^a': red\n"+
" '^b': green\n"), &config)
assert.NoError(t, err)
err = yaml.Unmarshal([]byte("patterns:\n"+
" '^c': blue\n"+
" '^b': yellow\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^c", Color: "blue"},
{Pattern: "^b", Color: "yellow"},
{Pattern: "^a", Color: "red"},
}, config.Patterns)
}
func TestColorPatternsMustBeAMapping(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns: 5\n"), &config)
assert.ErrorContains(t, err, "cannot unmarshal !!int `5` into map[string]string")
}
func TestColorPatternsSurviveMarshalling(t *testing.T) {
config := colorPatternsConfig{Patterns: ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "true", Color: "blue"},
}}
content, err := yaml.Marshal(config)
assert.NoError(t, err)
var roundTripped colorPatternsConfig
err = yaml.Unmarshal(content, &roundTripped)
assert.NoError(t, err)
assert.Equal(t, config, roundTripped)
}
+49 -14
View File
@@ -1,8 +1,9 @@
package config
import (
"strconv"
"fmt"
"strings"
"text/template"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -60,18 +61,23 @@ func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
return currentDiffRendererConfig.getType()
}
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
// DiffRendererValues are what the command of a diff renderer can refer to.
type DiffRendererValues struct {
// The width of the view that the diff is rendered into
Width int
// The number of lines of context around each hunk
DiffContext uint64
// Whether the terminal has a light background
LightBackground bool
}
func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) (string, error) {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
return ""
return "", nil
}
templateValues := map[string]string{
"columnWidth": strconv.Itoa(width/2 - 6),
}
commandTemplate := string(currentDiffRendererConfig.Command)
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
return currentDiffRendererConfig.resolveCommand(values)
}
func (self *DiffRendererConfigManager) GetColorArg() string {
@@ -87,17 +93,46 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
return colorArg
}
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
return ""
return "", nil
}
templateValues := map[string]string{
"diffContext": strconv.Itoa(int(diffContext)),
return currentDiffRendererConfig.resolveCommand(values)
}
// resolveCommand resolves the renderer's command, which is a Go template with
// the values it can refer to as its variables. A variable can be written with
// or without the leading dot, as in {{.width}} or {{width}}.
func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) (string, error) {
colorScheme := "dark"
if values.LightBackground {
colorScheme = "light"
}
variables := map[string]any{
"width": values.Width,
"colorScheme": colorScheme,
}
switch self.getType() {
case DiffRendererType_StdinFilter:
variables["columnWidth"] = values.Width/2 - 6
case DiffRendererType_ExtDiff:
variables["diffContext"] = values.DiffContext
case DiffRendererType_RawGit:
// has no command
}
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
funcs := template.FuncMap{}
for name, value := range variables {
funcs[name] = func() any { return value }
}
command, err := utils.ResolveTemplate(string(self.Command), variables, funcs)
if err != nil {
return "", fmt.Errorf("git.diffRenderers: can't use the command '%s': %w", self.Command, err)
}
return command, nil
}
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
@@ -63,6 +63,158 @@ func TestCurrentDiffRendererName(t *testing.T) {
}
}
func TestGetStdinFilterCommand(t *testing.T) {
scenarios := []struct {
name string
diffRendererConfig DiffRendererConfig
width int
lightBackground bool
expected string
expectedError string
}{
{
name: "a command without template variables is passed through",
diffRendererConfig: DiffRendererConfig{Command: "delta --paging=never"},
width: 120,
expected: "delta --paging=never",
},
{
name: "the width the diff is rendered at",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
width: 120,
expected: "delta --width=120",
},
{
name: "the width of one side of a side-by-side rendering",
diffRendererConfig: DiffRendererConfig{Command: "ydiff -p cat -w {{columnWidth}}"},
width: 120,
expected: "ydiff -p cat -w 54",
},
{
name: "a template variable can also be written with a leading dot",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.width}}"},
width: 120,
expected: "delta --width=120",
},
{
name: "the color scheme on a dark background",
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
width: 120,
expected: "delta --dark",
},
{
name: "the color scheme on a light background",
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
width: 120,
lightBackground: true,
expected: "delta --light",
},
{
name: "the command can choose between options by the color scheme",
diffRendererConfig: DiffRendererConfig{Command: `delta --syntax-theme={{if eq .colorScheme "light"}}GitHub{{else}}Dracula{{end}}`},
width: 120,
lightBackground: true,
expected: "delta --syntax-theme=GitHub",
},
{
name: "the command can use template expressions",
diffRendererConfig: DiffRendererConfig{Command: "delta{{if gt .width 100}} --side-by-side{{end}}"},
width: 120,
expected: "delta --side-by-side",
},
{
name: "an unknown template variable is an error",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.widht}}"},
width: 120,
expectedError: "can't use the command 'delta --width={{.widht}}'",
},
{
name: "an unknown template variable without a leading dot is an error too",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{widht}}"},
width: 120,
expectedError: "can't use the command 'delta --width={{widht}}'",
},
{
name: "nothing is returned for a renderer of another type",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
width: 120,
expected: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
command, err := config.GetStdinFilterCommand(DiffRendererValues{Width: s.width, LightBackground: s.lightBackground})
if s.expectedError != "" {
assert.ErrorContains(t, err, s.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, s.expected, command)
}
})
}
}
func TestGetExternalDiffCommand(t *testing.T) {
scenarios := []struct {
name string
diffRendererConfig DiffRendererConfig
expected string
expectedError string
}{
{
name: "a command without template variables is passed through",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: "difft --color=always",
},
{
name: "the width the diff is rendered at",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
expected: "difft --width=120",
},
{
name: "the color scheme",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --background={{colorScheme}}"},
expected: "difft --background=dark",
},
{
name: "the width alongside the diff context size",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}} --context={{diffContext}}"},
expected: "difft --width=120 --context=3",
},
{
name: "a variable of stdin filters is an error",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"},
expectedError: "can't use the command 'difft --width={{columnWidth}}'",
},
{
name: "nothing is returned for a renderer of another type",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
expected: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
command, err := config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3})
if s.expectedError != "" {
assert.ErrorContains(t, err, s.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, s.expected, command)
}
})
}
}
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
+105
View File
@@ -0,0 +1,105 @@
package config
import (
"fmt"
"maps"
"math"
"reflect"
"slices"
"strconv"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// ThemeForBackground returns gui.theme with the overrides for a dark or a light
// background applied, and with the defaults for that background in the fields
// that neither of them sets. backgroundColor is the terminal's background color
// as #rrggbb, or empty if we don't know it.
func (c *GuiConfig) ThemeForBackground(lightBackground bool, backgroundColor string) ThemeConfig {
override := lo.Ternary(lightBackground, c.LightTheme, c.DarkTheme)
return mergeThemes(override, c.Theme, themeDefaults(lightBackground, backgroundColor))
}
// themeDefaults returns the defaults of the theme fields whose default depends
// on the terminal's background. GetDefaultConfig leaves these fields empty in
// gui.theme, so that a value there comes from the user, and wins over these.
//
// Most of them are derived from the background color, so that they keep the
// same distance from it however dark or light it is. If we don't know the
// background color, we assume black or white.
func themeDefaults(lightBackground bool, backgroundColor string) ThemeConfig {
if len(backgroundColor) != 7 || !utils.IsValidHexValue(backgroundColor) {
backgroundColor = lo.Ternary(lightBackground, "#ffffff", "#000000")
}
if lightBackground {
return ThemeConfig{
// The colors of a light palette are dark enough to read as text on
// the background, so none of them works as a background for text.
// Some blue mixed into the background keeps the colored text on the
// selected line as readable as elsewhere.
SelectedLineBgColor: []string{mixHexColors(backgroundColor, "#0064ff", 0.25)},
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#000000", 0.15)},
}
}
return ThemeConfig{
// Dark palettes make their blue dark enough to work as a background for
// text. Unlike a color derived from a dark background, it doesn't turn
// into black on terminals with only 8 colors.
SelectedLineBgColor: []string{"blue"},
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#ffffff", 0.3)},
}
}
// mixHexColors mixes two colors given as #rrggbb. amount is how much of b to
// take: 0 gives a, 1 gives b.
func mixHexColors(a, b string, amount float64) string {
channel := func(color string, i int) float64 {
value, _ := strconv.ParseUint(color[1+2*i:3+2*i], 16, 8)
return float64(value)
}
result := "#"
for i := range 3 {
mixed := channel(a, i) + (channel(b, i)-channel(a, i))*amount
result += fmt.Sprintf("%02x", int(math.Round(mixed)))
}
return result
}
// mergeThemes takes each field from the first of the themes that sets it. For
// maps and color patterns it merges the entries instead, and an entry of an
// earlier theme wins over one with the same key in a later theme.
func mergeThemes(themes ...ThemeConfig) ThemeConfig {
var result ThemeConfig
resultValue := reflect.ValueOf(&result).Elem()
for i := range resultValue.NumField() {
values := lo.Map(themes, func(theme ThemeConfig, _ int) any {
return reflect.ValueOf(theme).Field(i).Interface()
})
resultValue.Field(i).Set(reflect.ValueOf(mergeThemeField(values)))
}
return result
}
func mergeThemeField(values []any) any {
switch values[0].(type) {
case []string:
return lo.FindOrElse(lo.Map(values, func(v any, _ int) []string { return v.([]string) }), nil,
func(v []string) bool { return len(v) > 0 })
case map[string]string:
merged := map[string]string{}
for _, v := range slices.Backward(values) {
maps.Copy(merged, v.(map[string]string))
}
return merged
case ColorPatterns:
return lo.Reduce(values, func(merged ColorPatterns, v any, _ int) ColorPatterns {
return merged.over(v.(ColorPatterns))
}, nil)
default:
panic(fmt.Sprintf("don't know how to merge a theme field of type %T", values[0]))
}
}
+127
View File
@@ -0,0 +1,127 @@
package config
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestThemeForBackground(t *testing.T) {
gui := GuiConfig{
Theme: ThemeConfig{
ActiveBorderColor: []string{"green"},
InactiveBorderColor: []string{"default"},
AuthorColors: map[string]string{"Alice": "red", "Bob": "blue"},
BranchColorPatterns: ColorPatterns{
{Pattern: "^docs/", Color: "cyan"},
{Pattern: "^feature/", Color: "green"},
},
},
DarkTheme: ThemeConfig{
ActiveBorderColor: []string{"yellow"},
},
LightTheme: ThemeConfig{
InactiveBorderColor: []string{"#777777"},
AuthorColors: map[string]string{"Bob": "#000080"},
BranchColorPatterns: ColorPatterns{
{Pattern: "ISSUE", Color: "red"},
{Pattern: "^docs/", Color: "#008080"},
},
},
}
dark := gui.ThemeForBackground(false, "")
assert.Equal(t, []string{"yellow"}, dark.ActiveBorderColor)
assert.Equal(t, []string{"default"}, dark.InactiveBorderColor)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, dark.AuthorColors)
assert.Equal(t, gui.Theme.BranchColorPatterns, dark.BranchColorPatterns)
light := gui.ThemeForBackground(true, "")
assert.Equal(t, []string{"green"}, light.ActiveBorderColor)
assert.Equal(t, []string{"#777777"}, light.InactiveBorderColor)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "#000080"}, light.AuthorColors)
assert.Equal(t, ColorPatterns{
{Pattern: "ISSUE", Color: "red"},
{Pattern: "^docs/", Color: "#008080"},
{Pattern: "^feature/", Color: "green"},
}, light.BranchColorPatterns)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, gui.Theme.AuthorColors,
"merging must leave the themes it merges alone")
}
func TestEveryThemeFieldCanBeOverridden(t *testing.T) {
var override ThemeConfig
overrideValue := reflect.ValueOf(&override).Elem()
for i := range overrideValue.NumField() {
field := overrideValue.Field(i)
switch field.Interface().(type) {
case []string:
field.Set(reflect.ValueOf([]string{"#123456"}))
case map[string]string:
field.Set(reflect.ValueOf(map[string]string{"key": "#123456"}))
case ColorPatterns:
field.Set(reflect.ValueOf(ColorPatterns{{Pattern: "key", Color: "#123456"}}))
default:
t.Fatalf("no test value for theme field %s", overrideValue.Type().Field(i).Name)
}
}
gui := GetDefaultConfig().Gui
gui.DarkTheme = override
assert.Equal(t, override, gui.ThemeForBackground(false, ""))
}
func TestThemeForBackgroundFallsBackToTheDefaultsForTheBackground(t *testing.T) {
gui := GetDefaultConfig().Gui
assert.Equal(t, []string{"#4d4d4d"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"#d9d9d9"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
gui.Theme.InactiveViewSelectedLineBgColor = []string{"bold"}
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
gui.LightTheme.InactiveViewSelectedLineBgColor = []string{"white"}
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"white"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
}
func TestBackgroundDefaultsAreDerivedFromTheBackgroundColor(t *testing.T) {
gui := GetDefaultConfig().Gui
assert.Equal(t, []string{"#626262"}, gui.ThemeForBackground(false, "#1e1e1e").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"#d7d1c1"}, gui.ThemeForBackground(true, "#fdf6e3").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"blue"}, gui.ThemeForBackground(false, "#1e1e1e").SelectedLineBgColor)
assert.Equal(t, []string{"#bed2ea"}, gui.ThemeForBackground(true, "#fdf6e3").SelectedLineBgColor)
assert.Equal(t, []string{"#bfd8ff"}, gui.ThemeForBackground(true, "").SelectedLineBgColor)
}
// If gui.theme had a default for a field that also has a default for the
// terminal's background, the former would always win.
func TestFieldsWithBackgroundDefaultsHaveNoDefaultInGuiTheme(t *testing.T) {
genericDefaults := setThemeFields(GetDefaultConfig().Gui.Theme)
for _, field := range setThemeFields(themeDefaults(false, "")) {
assert.NotContains(t, genericDefaults, field)
}
for _, field := range setThemeFields(themeDefaults(true, "")) {
assert.NotContains(t, genericDefaults, field)
}
}
// If only one of the backgrounds had a default for a field, the field would
// have no value at all with the other.
func TestDarkAndLightDefaultsSetTheSameFields(t *testing.T) {
assert.Equal(t, setThemeFields(themeDefaults(false, "")), setThemeFields(themeDefaults(true, "")))
}
func setThemeFields(theme ThemeConfig) []string {
var fields []string
themeValue := reflect.ValueOf(theme)
for i := range themeValue.NumField() {
if themeValue.Field(i).Len() > 0 {
fields = append(fields, themeValue.Type().Field(i).Name)
}
}
return fields
}
+32 -20
View File
@@ -68,13 +68,6 @@ func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
}
type GuiConfig struct {
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
// Deprecated: use branchColorPatterns instead
BranchColors map[string]string `yaml:"branchColors" jsonschema:"deprecated"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
BranchColorPatterns map[string]string `yaml:"branchColorPatterns"`
// Custom icons for filenames and file extensions
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
CustomIcons CustomIconsConfig `yaml:"customIcons"`
@@ -141,9 +134,19 @@ type GuiConfig struct {
// Format used when displaying time if the time is less than 24 hours ago.
// Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
ShortTimeFormat string `yaml:"shortTimeFormat"`
// Whether the terminal has a dark or a light background. This decides whether 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to stand out against it.
// One of: 'auto' (default) | 'dark' | 'light'
// With 'auto', lazygit asks the terminal, and assumes a dark background if the terminal doesn't tell.
ColorScheme string `yaml:"colorScheme" jsonschema:"enum=auto,enum=dark,enum=light"`
// Config relating to colors and styles.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
Theme ThemeConfig `yaml:"theme"`
// Colors and styles that override those in 'theme' when the terminal has a dark background. It has the same fields as 'theme'.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
DarkTheme ThemeConfig `yaml:"darkTheme"`
// Colors and styles that override those in 'theme' when the terminal has a light background. It has the same fields as 'theme'.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
LightTheme ThemeConfig `yaml:"lightTheme"`
// Config relating to the commit length indicator
CommitLength CommitLengthConfig `yaml:"commitLength"`
// If true, show the '5 of 20' footer at the bottom of list views
@@ -237,10 +240,15 @@ type ThemeConfig struct {
SearchingActiveBorderColor []string `yaml:"searchingActiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"`
// Color of keybindings help text in the bottom line
OptionsTextColor []string `yaml:"optionsTextColor" jsonschema:"minItems=1,uniqueItems=true"`
// Color and attributes of the text of the selected line. The attributes are added to those of the text, and a color replaces the colors of the text.
// Set it to 'default' to leave the text as it is, e.g. if you don't want the selected line in bold.
SelectedLineFgColor []string `yaml:"selectedLineFgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Background color of selected line.
// Default: 'blue' if the terminal has a dark background, or a suitable RGB blue computed from the background color if it is light.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Background color of selected line when view doesn't have focus.
// Default: a suitable RGB grey computed from the terminal's background color.
InactiveViewSelectedLineBgColor []string `yaml:"inactiveViewSelectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Foreground color of copied commit
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"`
@@ -254,6 +262,10 @@ type ThemeConfig struct {
UnstagedChangesColor []string `yaml:"unstagedChangesColor" jsonschema:"minItems=1,uniqueItems=true"`
// Default text color
DefaultFgColor []string `yaml:"defaultFgColor" jsonschema:"minItems=1,uniqueItems=true"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
BranchColorPatterns ColorPatterns `yaml:"branchColorPatterns"`
}
type CommitLengthConfig struct {
@@ -316,7 +328,7 @@ type GitConfig struct {
AutoRefresh bool `yaml:"autoRefresh"`
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
// If true, pass the --all arg to git fetch
@@ -881,19 +893,19 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
Language: "auto",
TimeFormat: "02 Jan 06",
ShortTimeFormat: time.Kitchen,
ColorScheme: "auto",
Theme: ThemeConfig{
ActiveBorderColor: []string{"green", "bold"},
SearchingActiveBorderColor: []string{"cyan", "bold"},
InactiveBorderColor: []string{"default"},
OptionsTextColor: []string{"blue"},
SelectedLineBgColor: []string{"blue"},
InactiveViewSelectedLineBgColor: []string{"bold"},
CherryPickedCommitBgColor: []string{"cyan"},
CherryPickedCommitFgColor: []string{"blue"},
MarkedBaseCommitBgColor: []string{"yellow"},
MarkedBaseCommitFgColor: []string{"blue"},
UnstagedChangesColor: []string{"red"},
DefaultFgColor: []string{"default"},
ActiveBorderColor: []string{"green", "bold"},
SearchingActiveBorderColor: []string{"cyan", "bold"},
InactiveBorderColor: []string{"dim"},
OptionsTextColor: []string{"blue"},
SelectedLineFgColor: []string{"bold"},
CherryPickedCommitBgColor: []string{"cyan"},
CherryPickedCommitFgColor: []string{"blue"},
MarkedBaseCommitBgColor: []string{"yellow"},
MarkedBaseCommitFgColor: []string{"blue"},
UnstagedChangesColor: []string{"red"},
DefaultFgColor: []string{"default"},
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
+18
View File
@@ -18,6 +18,10 @@ func (config *UserConfig) Validate() error {
[]string{"dashboard", "allBranchesLog"}); err != nil {
return err
}
if err := validateEnum("gui.colorScheme", config.Gui.ColorScheme,
[]string{"auto", "dark", "light"}); err != nil {
return err
}
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err
@@ -120,10 +124,16 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
}
if err := validateDiffRendererCommand(diffRenderer); err != nil {
return err
}
case "extDiff":
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
}
if err := validateDiffRendererCommand(diffRenderer); err != nil {
return err
}
case "rawGit":
if diffRenderer.Command != "" {
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
@@ -135,6 +145,14 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
return nil
}
// validateDiffRendererCommand resolves the command with made-up values, so that
// a mistake in it shows up when the config is loaded rather than when a diff
// is rendered.
func validateDiffRendererCommand(diffRenderer DiffRendererConfig) error {
_, err := diffRenderer.resolveCommand(DiffRendererValues{Width: 80, DiffContext: 3})
return err
}
func validateEnum(name string, value string, allowedValues []string) error {
if slices.Contains(allowedValues, value) {
return nil
@@ -409,9 +409,14 @@ func TestUserConfigValidate_diffRenderers(t *testing.T) {
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
{name: "stdinFilter with a template", diffRenderer: DiffRendererConfig{Command: "delta --width={{width}}{{if gt .width 100}} --side-by-side{{end}}"}, valid: true},
{name: "stdinFilter with an unknown template variable", diffRenderer: DiffRendererConfig{Command: "delta --width={{.widht}}"}, valid: false},
{name: "stdinFilter with a broken template", diffRenderer: DiffRendererConfig{Command: "delta {{if .width}}"}, valid: false},
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
{name: "external diff with a template", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --context={{diffContext}}"}, valid: true},
{name: "external diff with a variable of stdin filters", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, valid: false},
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
+497
View File
@@ -0,0 +1,497 @@
package gocui
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v3"
"github.com/lucasb-eyer/go-colorful"
)
// ColorScheme says whether the terminal shows light text on a dark background,
// or dark text on a light one.
type ColorScheme int
const (
ColorSchemeUnknown ColorScheme = iota
ColorSchemeDark
ColorSchemeLight
)
func (self ColorScheme) String() string {
switch self {
case ColorSchemeDark:
return "dark"
case ColorSchemeLight:
return "light"
default:
return "unknown"
}
}
// DetectedColorScheme is what the terminal has told us about its colors.
type DetectedColorScheme struct {
ColorScheme ColorScheme
// The background color that ColorScheme was derived from, as #rrggbb. It is
// empty if the terminal didn't tell us its background color; ColorScheme is
// then whatever the terminal said about itself, if anything.
Background string
}
func (self DetectedColorScheme) String() string {
if self.Background != "" {
return fmt.Sprintf("%s (background %s)", self.ColorScheme, self.Background)
}
if self.ColorScheme != ColorSchemeUnknown {
return fmt.Sprintf("%s (reported by the terminal)", self.ColorScheme)
}
return self.ColorScheme.String()
}
const (
// Makes the terminal send a color scheme report whenever its colors change
enableColorSchemeReports = "\x1b[?2031h"
disableColorSchemeReports = "\x1b[?2031l"
// Asks for a color scheme report: CSI ? 997 ; 1 n for dark, 2 for light
requestColorScheme = "\x1b[?996n"
// Asks for the background color: OSC 11 ; rgb:RRRR/GGGG/BBBB, ended by BEL
// or ST. Ending the request with BEL makes more terminals reply.
requestBackgroundColor = "\x1b]11;?\a"
)
// colorSchemeTty is a tcell.Tty that finds out the terminal's color scheme.
//
// Some terminals say whether they are dark or light, and can report it again
// whenever that changes. What they base this on varies, though: some go by the
// terminal's own colors, others by the dark or light mode of the operating
// system, whether or not the terminal follows it. So we also ask for the
// background color, and when the terminal tells us that, it decides; a color
// scheme report is then only a sign that the background may have changed.
//
// The queries go out when tcell starts the tty, which is before tcell sends its
// own queries during Screen.Init and waits for their answer. Terminals answer in
// order, so the answers to ours have arrived by the time Init returns, and we
// know the color scheme before drawing anything without waiting for it.
//
// tcell doesn't understand the answers and drops them, so all we need to do is
// watch for them in the input as it goes by.
type colorSchemeTty struct {
tcell.Tty
// What we send when the tty starts
queries string
// Guards all of the fields below, and serializes our writes to the terminal
// with tcell's
mutex sync.Mutex
started bool
background colorful.Color
// Whether the terminal has told us its background color
haveBackground bool
// Whether we have asked for the background color and are waiting for the
// answer
backgroundRequested bool
// The color scheme that the terminal last reported for itself
reported ColorScheme
// Whether we have asked for a color scheme report and are waiting for it
colorSchemeRequested bool
// Closed once no answer that waitForReplies waits for is outstanding
repliesArrived chan struct{}
notified DetectedColorScheme
onChange func(DetectedColorScheme)
// Only used by Read, which tcell never calls concurrently with itself
scanner terminalReplyScanner
}
var _ tcell.Tty = &colorSchemeTty{}
func newColorSchemeTty(tty tcell.Tty) *colorSchemeTty {
return &colorSchemeTty{
Tty: tty,
queries: colorSchemeQueries(
os.Getenv("TERM"),
os.Getenv("TERM_PROGRAM"),
os.Getenv("TCELL_NEGOTIATE"),
),
}
}
// colorSchemeQueries returns what to ask the terminal. It leaves out the
// terminals that tcell doesn't send its own queries to (see
// applyKnownTerminalProfile and the legacy terminals in tScreen.Init), except
// for those that are known to answer a request for the background color.
func colorSchemeQueries(term string, termProgram string, tcellNegotiate string) string {
if tcellNegotiate == "disable" {
return ""
}
if term == "st" || strings.HasPrefix(term, "st-") ||
strings.HasPrefix(term, "vt") || strings.Contains(term, "ansi") ||
term == "linux" || term == "sun" || term == "sun-color" {
return ""
}
if termProgram == "Apple_Terminal" || termProgram == "WezTerm" {
return requestBackgroundColor
}
return enableColorSchemeReports + requestColorScheme + requestBackgroundColor
}
// subscribe sets a function to call whenever the detected color scheme changes,
// and returns the one detected so far. The function is called on the goroutine
// that reads from the tty, so it must not block.
func (self *colorSchemeTty) subscribe(onChange func(DetectedColorScheme)) DetectedColorScheme {
self.mutex.Lock()
defer self.mutex.Unlock()
self.onChange = onChange
self.notified = self.detected()
return self.notified
}
func (self *colorSchemeTty) Start() error {
if err := self.Tty.Start(); err != nil {
return err
}
self.mutex.Lock()
defer self.mutex.Unlock()
self.started = true
self.writeLocked(self.queries)
if strings.Contains(self.queries, requestBackgroundColor) {
self.backgroundRequested = true
}
if strings.Contains(self.queries, requestColorScheme) {
self.colorSchemeRequested = true
}
return nil
}
// waitForReplies waits until the terminal has answered what we asked it, but
// no longer than the timeout. Call it before handing the terminal to another
// program, or the answers would reach that program as if they were typed. It
// only waits for the answers that the terminal has given before, so a terminal
// that doesn't answer at all costs no time.
func (self *colorSchemeTty) waitForReplies(timeout time.Duration) {
self.mutex.Lock()
if !self.awaitingRepliesLocked() {
self.mutex.Unlock()
return
}
if self.repliesArrived == nil {
self.repliesArrived = make(chan struct{})
}
repliesArrived := self.repliesArrived
self.mutex.Unlock()
select {
case <-repliesArrived:
case <-time.After(timeout):
}
}
func (self *colorSchemeTty) awaitingRepliesLocked() bool {
return (self.backgroundRequested && self.haveBackground) ||
(self.colorSchemeRequested && self.reported != ColorSchemeUnknown)
}
func (self *colorSchemeTty) Stop() error {
self.mutex.Lock()
// Otherwise, the program we hand the terminal to would receive the reports
// as if they were typed
if strings.Contains(self.queries, enableColorSchemeReports) {
self.writeLocked(disableColorSchemeReports)
}
self.started = false
self.mutex.Unlock()
return self.Tty.Stop()
}
func (self *colorSchemeTty) Write(p []byte) (int, error) {
self.mutex.Lock()
defer self.mutex.Unlock()
return self.Tty.Write(p)
}
func (self *colorSchemeTty) Read(p []byte) (int, error) {
n, err := self.Tty.Read(p)
for _, reply := range self.scanner.scan(p[:n]) {
self.handleReply(reply)
}
return n, err
}
// onFocusGained asks for the background color again, for the terminals that
// tell us their background color but not when it changes. When the operating
// system switches between dark and light mode, the user is usually busy
// elsewhere, so coming back to the terminal is a good time to check.
func (self *colorSchemeTty) onFocusGained() {
self.mutex.Lock()
defer self.mutex.Unlock()
self.requestBackgroundColorLocked()
}
func (self *colorSchemeTty) handleReply(reply terminalReply) {
self.mutex.Lock()
if reply.background != nil {
self.background = *reply.background
self.haveBackground = true
self.backgroundRequested = false
} else {
self.reported = reply.colorScheme
self.colorSchemeRequested = false
self.requestBackgroundColorLocked()
}
if self.repliesArrived != nil && !self.awaitingRepliesLocked() {
close(self.repliesArrived)
self.repliesArrived = nil
}
detected := self.detected()
onChange := self.onChange
changed := detected != self.notified
if changed {
self.notified = detected
}
self.mutex.Unlock()
if changed && onChange != nil {
onChange(detected)
}
}
// requestBackgroundColorLocked asks for the background color, if the terminal
// has told us its background color before and isn't about to do it anyway.
func (self *colorSchemeTty) requestBackgroundColorLocked() {
if !self.started || !self.haveBackground || self.backgroundRequested {
return
}
self.writeLocked(requestBackgroundColor)
self.backgroundRequested = true
}
func (self *colorSchemeTty) detected() DetectedColorScheme {
if !self.haveBackground {
return DetectedColorScheme{ColorScheme: self.reported}
}
return DetectedColorScheme{
ColorScheme: colorSchemeOfBackground(self.background),
Background: self.background.Hex(),
}
}
func (self *colorSchemeTty) writeLocked(s string) {
if s == "" {
return
}
// If the terminal is gone, tcell finds out when it next writes or reads
_, _ = self.Tty.Write([]byte(s))
}
func colorSchemeOfBackground(background colorful.Color) ColorScheme {
lightness, _, _ := background.Lab()
if lightness < 0.5 {
return ColorSchemeDark
}
return ColorSchemeLight
}
// terminalReply is one of the answers that colorSchemeTty asks the terminal
// for. Exactly one of its fields is set.
type terminalReply struct {
colorScheme ColorScheme
background *colorful.Color
}
type terminalReplyScannerState int
const (
scanningText terminalReplyScannerState = iota
scanningEscape
scanningCsi
scanningOsc
scanningOscEscape
)
// No reply we look for is longer than this; we don't look at longer sequences
const maxTerminalReplyLength = 64
// terminalReplyScanner finds the terminal's answers to our color scheme queries
// in its input. The input arrives in chunks, which may split an answer anywhere,
// so the scanner keeps its state from one chunk to the next.
type terminalReplyScanner struct {
state terminalReplyScannerState
sequence []byte
oversized bool
}
func (self *terminalReplyScanner) scan(input []byte) []terminalReply {
var replies []terminalReply
for _, b := range input {
switch self.state {
case scanningText:
if b == '\x1b' {
self.state = scanningEscape
}
case scanningEscape:
self.scanEscape(b)
case scanningCsi:
switch {
case b == '\x1b':
self.state = scanningEscape
case b >= 0x20 && b <= 0x3f: // parameter and intermediate bytes
self.appendToSequence(b)
case b >= 0x40 && b <= 0x7e: // final byte
self.state = scanningText
if b == 'n' && !self.oversized {
if colorScheme, ok := parseColorSchemeReport(string(self.sequence)); ok {
replies = append(replies, terminalReply{colorScheme: colorScheme})
}
}
default:
self.state = scanningText
}
case scanningOsc:
switch b {
case '\a':
self.state = scanningText
replies = self.appendOscReply(replies)
case '\x1b':
self.state = scanningOscEscape
default:
self.appendToSequence(b)
}
case scanningOscEscape:
if b == '\\' {
self.state = scanningText
replies = self.appendOscReply(replies)
} else {
// Not a string terminator, so a new escape sequence has begun and
// the one before it was cut short
self.scanEscape(b)
}
}
}
return replies
}
// scanEscape handles the byte after an ESC.
func (self *terminalReplyScanner) scanEscape(b byte) {
switch b {
case '[':
self.startSequence(scanningCsi)
case ']':
self.startSequence(scanningOsc)
case '\x1b':
self.state = scanningEscape
default:
self.state = scanningText
}
}
func (self *terminalReplyScanner) startSequence(state terminalReplyScannerState) {
self.state = state
self.sequence = self.sequence[:0]
self.oversized = false
}
func (self *terminalReplyScanner) appendToSequence(b byte) {
if len(self.sequence) >= maxTerminalReplyLength {
self.oversized = true
return
}
self.sequence = append(self.sequence, b)
}
func (self *terminalReplyScanner) appendOscReply(replies []terminalReply) []terminalReply {
if self.oversized {
return replies
}
if background, ok := parseBackgroundColorReport(string(self.sequence)); ok {
replies = append(replies, terminalReply{background: &background})
}
return replies
}
// parseColorSchemeReport parses the parameters of CSI ? 997 ; <n> n
func parseColorSchemeReport(params string) (ColorScheme, bool) {
switch params {
case "?997;1":
return ColorSchemeDark, true
case "?997;2":
return ColorSchemeLight, true
default:
return ColorSchemeUnknown, false
}
}
// parseBackgroundColorReport parses the content of OSC 11 ; rgb:R/G/B, where
// each of R, G and B has from one to four hex digits. Some terminals send
// rgba:R/G/B/A instead.
func parseBackgroundColorReport(content string) (colorful.Color, bool) {
spec, ok := strings.CutPrefix(content, "11;")
if !ok {
return colorful.Color{}, false
}
var components []string
if rgb, ok := strings.CutPrefix(spec, "rgb:"); ok {
components = strings.Split(rgb, "/")
if len(components) != 3 {
return colorful.Color{}, false
}
} else if rgba, ok := strings.CutPrefix(spec, "rgba:"); ok {
components = strings.Split(rgba, "/")
if len(components) != 4 {
return colorful.Color{}, false
}
} else {
return colorful.Color{}, false
}
values := [3]float64{}
for i := range values {
component := components[i]
if len(component) < 1 || len(component) > 4 {
return colorful.Color{}, false
}
value, err := strconv.ParseUint(component, 16, 16)
if err != nil {
return colorful.Color{}, false
}
maxValue := uint64(1)<<(4*len(component)) - 1
values[i] = float64(value) / float64(maxValue)
}
return colorful.Color{R: values[0], G: values[1], B: values[2]}, true
}
+402
View File
@@ -0,0 +1,402 @@
package gocui
import (
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v3"
"github.com/stretchr/testify/assert"
)
func describeReplies(replies []terminalReply) []string {
result := []string{}
for _, reply := range replies {
if reply.background != nil {
result = append(result, reply.background.Hex())
} else {
result = append(result, reply.colorScheme.String())
}
}
return result
}
func TestTerminalReplyScanner(t *testing.T) {
scenarios := []struct {
name string
input string
expected []string
}{
{
name: "dark report",
input: "\x1b[?997;1n",
expected: []string{"dark"},
},
{
name: "light report",
input: "\x1b[?997;2n",
expected: []string{"light"},
},
{
name: "background ended by ST",
input: "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\",
expected: []string{"#1e1e1e"},
},
{
name: "background ended by BEL",
input: "\x1b]11;rgb:ffff/ffff/ffff\a",
expected: []string{"#ffffff"},
},
{
name: "background with two hex digits per component",
input: "\x1b]11;rgb:fd/f6/e3\a",
expected: []string{"#fdf6e3"},
},
{
name: "background with one hex digit per component",
input: "\x1b]11;rgb:f/0/f\a",
expected: []string{"#ff00ff"},
},
{
name: "background with alpha",
input: "\x1b]11;rgba:0000/2b2b/3636/ffff\a",
expected: []string{"#002b36"},
},
{
name: "replies among other input",
input: "j\x1b[?997;1n\x1b[A\x1b[<0;10;5M\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?62;22c",
expected: []string{"dark", "#000000"},
},
{
name: "other reports",
input: "\x1b[?997;3n\x1b[0n\x1b[?996n\x1b]10;rgb:ffff/ffff/ffff\a\x1b]4;1;rgb:ffff/0000/0000\a",
expected: []string{},
},
{
name: "malformed backgrounds",
input: "\x1b]11;rgb:zz/00/00\a\x1b]11;rgb:10/20\a\x1b]11;rgb:12345/0/0\a\x1b]11;rgb://\a\x1b]11;#ffffff\a",
expected: []string{},
},
{
name: "reply after a sequence that was cut short",
input: "\x1b]11;rgb:\x1b[?997;2n\x1b[?99\x1b]11;rgb:00/00/00\a",
expected: []string{"light", "#000000"},
},
{
name: "reply after an oversized sequence",
input: "\x1b]11;" + strings.Repeat("x", 100) + "\a\x1b[?" + strings.Repeat("9", 100) + "n\x1b[?997;1n",
expected: []string{"dark"},
},
{
name: "oversized sequence that starts like a reply",
input: "\x1b]11;rgb:00/00/00" + strings.Repeat("x", 100) + "\a",
expected: []string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
scanner := terminalReplyScanner{}
assert.Equal(t, s.expected, describeReplies(scanner.scan([]byte(s.input))))
})
t.Run(s.name+", split in two", func(t *testing.T) {
for i := range len(s.input) {
scanner := terminalReplyScanner{}
replies := scanner.scan([]byte(s.input[:i]))
replies = append(replies, scanner.scan([]byte(s.input[i:]))...)
assert.Equal(t, s.expected, describeReplies(replies), "split at %d", i)
}
})
t.Run(s.name+", byte by byte", func(t *testing.T) {
scanner := terminalReplyScanner{}
replies := []terminalReply{}
for i := range len(s.input) {
replies = append(replies, scanner.scan([]byte{s.input[i]})...)
}
assert.Equal(t, s.expected, describeReplies(replies))
})
}
}
func TestColorSchemeQueries(t *testing.T) {
allQueries := enableColorSchemeReports + requestColorScheme + requestBackgroundColor
scenarios := []struct {
name string
term string
termProgram string
tcellNegotiate string
expected string
}{
{name: "xterm", term: "xterm-256color", expected: allQueries},
{name: "tmux", term: "tmux-256color", termProgram: "tmux", expected: allQueries},
{name: "Terminal.app", term: "xterm-256color", termProgram: "Apple_Terminal", expected: requestBackgroundColor},
{name: "WezTerm", term: "xterm-256color", termProgram: "WezTerm", expected: requestBackgroundColor},
{name: "st", term: "st-256color", expected: ""},
{name: "Linux console", term: "linux", expected: ""},
{name: "VT100", term: "vt100", expected: ""},
{name: "negotiation disabled", term: "xterm-256color", tcellNegotiate: "disable", expected: ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, colorSchemeQueries(s.term, s.termProgram, s.tcellNegotiate))
})
}
}
type fakeTty struct {
input string
written strings.Builder
}
var _ tcell.Tty = &fakeTty{}
func (self *fakeTty) Start() error { return nil }
func (self *fakeTty) Stop() error { return nil }
func (self *fakeTty) Drain() error { return nil }
func (self *fakeTty) NotifyResize(chan<- bool) {}
func (self *fakeTty) WindowSize() (tcell.WindowSize, error) { return tcell.WindowSize{}, nil }
func (self *fakeTty) Close() error { return nil }
func (self *fakeTty) Write(p []byte) (int, error) { return self.written.Write(p) }
func (self *fakeTty) Read(p []byte) (int, error) {
n := copy(p, self.input)
self.input = self.input[n:]
return n, nil
}
func (self *fakeTty) takeWritten() string {
written := self.written.String()
self.written.Reset()
return written
}
type colorSchemeTtyTest struct {
fake *fakeTty
tty *colorSchemeTty
notifications []string
}
func newColorSchemeTtyTest(termProgram string) *colorSchemeTtyTest {
test := &colorSchemeTtyTest{fake: &fakeTty{}}
test.tty = &colorSchemeTty{
Tty: test.fake,
queries: colorSchemeQueries("xterm-256color", termProgram, ""),
}
test.tty.subscribe(func(colorScheme DetectedColorScheme) {
test.notifications = append(test.notifications, colorScheme.String())
})
return test
}
// feed has the terminal send input, and has tcell read it
func (self *colorSchemeTtyTest) feed(input string) {
self.fake.input = input
_, _ = self.tty.Read(make([]byte, 128))
}
func (self *colorSchemeTtyTest) takeNotifications() []string {
notifications := self.notifications
self.notifications = nil
return notifications
}
func TestColorSchemeTtyPrefersTheBackground(t *testing.T) {
test := newColorSchemeTtyTest("")
assert.NoError(t, test.tty.Start())
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
// The terminal says it's light, but its background is dark
test.feed("\x1b[?997;2n")
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
test.feed("\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\\x1b[?62;22c")
assert.Equal(t, []string{"dark (background #1e1e1e)"}, test.takeNotifications())
assert.Equal(t, "", test.fake.takeWritten())
// A later report only makes us ask for the background again
test.feed("\x1b[?997;2n")
assert.Nil(t, test.takeNotifications())
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
test.feed("\x1b[?997;2n")
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
// The same background again is no change
test.feed("\x1b[?997;2n")
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
assert.Nil(t, test.takeNotifications())
}
func TestColorSchemeTtyWithoutBackground(t *testing.T) {
test := newColorSchemeTtyTest("")
assert.NoError(t, test.tty.Start())
test.fake.takeWritten()
test.feed("\x1b[?997;1n\x1b[?62;22c")
assert.Equal(t, []string{"dark (reported by the terminal)"}, test.takeNotifications())
test.feed("\x1b[?997;2n")
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
assert.Equal(t, "", test.fake.takeWritten(), "a terminal that didn't answer before isn't asked again")
test.tty.onFocusGained()
assert.Equal(t, "", test.fake.takeWritten())
}
func TestColorSchemeTtyAsksAgainOnFocus(t *testing.T) {
test := newColorSchemeTtyTest("Apple_Terminal")
assert.NoError(t, test.tty.Start())
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
test.tty.onFocusGained()
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
test.feed("\x1b]11;rgb:0000/0000/0000\a")
assert.Equal(t, []string{"dark (background #000000)"}, test.takeNotifications())
test.tty.onFocusGained()
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
test.tty.onFocusGained()
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
test.feed("\x1b]11;rgb:ffff/ffff/ffff\a")
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
}
func TestColorSchemeTtyStopAndStart(t *testing.T) {
test := newColorSchemeTtyTest("")
assert.NoError(t, test.tty.Start())
test.fake.takeWritten()
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
assert.NoError(t, test.tty.Stop())
assert.Equal(t, disableColorSchemeReports, test.fake.takeWritten())
test.tty.onFocusGained()
assert.Equal(t, "", test.fake.takeWritten(), "nothing is written to a stopped tty")
// Anything may have changed while we were stopped, so ask again
assert.NoError(t, test.tty.Start())
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
}
func TestColorSchemeTtyStopWithoutReports(t *testing.T) {
test := newColorSchemeTtyTest("WezTerm")
assert.NoError(t, test.tty.Start())
test.fake.takeWritten()
assert.NoError(t, test.tty.Stop())
assert.Equal(t, "", test.fake.takeWritten(), "reports were never turned on")
}
func TestColorSchemeOfBackground(t *testing.T) {
for background, expected := range map[string]ColorScheme{
"rgb:0000/0000/0000": ColorSchemeDark,
"rgb:1e/1e/1e": ColorSchemeDark,
"rgb:00/2b/36": ColorSchemeDark,
"rgb:70/70/70": ColorSchemeDark,
"rgb:80/80/80": ColorSchemeLight,
"rgb:fd/f6/e3": ColorSchemeLight,
"rgb:ff/ff/ff": ColorSchemeLight,
} {
color, ok := parseBackgroundColorReport("11;" + background)
assert.True(t, ok)
assert.Equal(t, expected, colorSchemeOfBackground(color), background)
}
}
// startWaiting starts waiting for replies, with a timeout too long to matter
func (self *colorSchemeTtyTest) startWaiting() <-chan struct{} {
done := make(chan struct{})
go func() {
self.tty.waitForReplies(time.Minute)
close(done)
}()
return done
}
func isClosed(ch <-chan struct{}) bool {
select {
case <-ch:
return true
default:
return false
}
}
func assertDoneWaiting(t *testing.T, done <-chan struct{}) {
t.Helper()
assert.Eventually(t, func() bool { return isClosed(done) }, time.Second, time.Millisecond)
}
func assertStillWaiting(t *testing.T, done <-chan struct{}) {
t.Helper()
assert.Never(t, func() bool { return isClosed(done) }, 50*time.Millisecond, time.Millisecond)
}
func TestColorSchemeTtyWaitsForReplies(t *testing.T) {
test := newColorSchemeTtyTest("")
assert.NoError(t, test.tty.Start())
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
assertDoneWaiting(t, test.startWaiting())
assert.NoError(t, test.tty.Stop())
assert.NoError(t, test.tty.Start())
done := test.startWaiting()
assertStillWaiting(t, done)
test.feed("\x1b[?997;1n")
assertStillWaiting(t, done)
test.feed("\x1b]11;rgb:0000/0000/0000\a")
assertDoneWaiting(t, done)
test.tty.onFocusGained()
done = test.startWaiting()
assertStillWaiting(t, done)
test.feed("\x1b]11;rgb:0000/0000/0000\a")
assertDoneWaiting(t, done)
}
func TestColorSchemeTtyGivesUpWaiting(t *testing.T) {
test := newColorSchemeTtyTest("")
assert.NoError(t, test.tty.Start())
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
test.tty.onFocusGained()
start := time.Now()
test.tty.waitForReplies(20 * time.Millisecond)
assert.GreaterOrEqual(t, time.Since(start), 20*time.Millisecond)
}
func TestColorSchemeTtyDoesntWaitForRepliesThatNeverCame(t *testing.T) {
test := newColorSchemeTtyTest("")
// The terminal answers nothing
assert.NoError(t, test.tty.Start())
assertDoneWaiting(t, test.startWaiting())
// The terminal reports its color scheme, but not its background
test.feed("\x1b[?997;1n\x1b[?62;22c")
assert.NoError(t, test.tty.Stop())
assert.NoError(t, test.tty.Start())
done := test.startWaiting()
assertStillWaiting(t, done)
test.feed("\x1b[?997;1n")
assertDoneWaiting(t, done)
}
+48
View File
@@ -227,6 +227,12 @@ type Gui struct {
// readable from anywhere, so it's atomic.
focused atomic.Bool
// colorSchemeTty is nil when running headless. colorScheme and
// colorSchemeHandler are only touched on the UI thread.
colorSchemeTty *colorSchemeTty
colorScheme DetectedColorScheme
colorSchemeHandler func(DetectedColorScheme) error
// blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are
@@ -317,6 +323,18 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
// never happened.
g.focused.Store(true)
if g.colorSchemeTty != nil {
g.colorScheme = g.colorSchemeTty.subscribe(func(colorScheme DetectedColorScheme) {
g.UpdateBackground(func(g *Gui) error {
g.colorScheme = colorScheme
if g.colorSchemeHandler != nil {
return g.colorSchemeHandler(colorScheme)
}
return nil
})
})
}
return g, nil
}
@@ -374,6 +392,7 @@ func (g *Gui) WaitUntilIdle() {
// initialization and when gocui is not needed anymore.
func (g *Gui) Close() {
close(g.stop)
g.waitForColorSchemeReplies()
Screen.Fini()
}
@@ -708,6 +727,29 @@ func (g *Gui) SetFocusHandler(handler func(bool) error) {
g.focusHandler = handler
}
// DetectedColorScheme returns what the terminal has told us about its colors.
// It is known before the first layout, for the terminals that tell us at all.
func (g *Gui) DetectedColorScheme() DetectedColorScheme {
return g.colorScheme
}
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
// the terminal's colors change after startup.
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
g.colorSchemeHandler = handler
}
// Long enough for the round trip of a slow ssh connection
const colorSchemeReplyTimeout = 500 * time.Millisecond
// waitForColorSchemeReplies is for before we give up the terminal. tcell is
// still reading the input at that point, so the answers are consumed as usual.
func (g *Gui) waitForColorSchemeReplies() {
if g.colorSchemeTty != nil {
g.colorSchemeTty.waitForReplies(colorSchemeReplyTimeout)
}
}
func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string, string) error) {
g.openHyperlink = openHyperlinkFunc
}
@@ -2043,6 +2085,10 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
}
g.focused.Store(ev.Focused)
if ev.Focused && g.colorSchemeTty != nil {
g.colorSchemeTty.onFocusGained()
}
if g.focusHandler != nil {
return g.focusHandler(ev.Focused)
}
@@ -2059,6 +2105,8 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
// after re-engaging.
func (g *Gui) Suspend() error {
g.waitForColorSchemeReplies()
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
+8 -1
View File
@@ -56,7 +56,13 @@ var runeReplacements = map[rune]string{
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
s, e := tcell.NewScreen()
tty, e := tcell.NewDevTty()
if e != nil {
return e
}
colorSchemeTty := newColorSchemeTty(tty)
s, e := tcell.NewTerminfoScreenFromTty(colorSchemeTty)
if e != nil {
return e
}
@@ -68,6 +74,7 @@ func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
registerRuneFallbacks(s, runeReplacements)
g.screen = s
g.colorSchemeTty = colorSchemeTty
Screen = s
return nil
}
+17 -7
View File
@@ -138,6 +138,12 @@ type View struct {
// focus.
InactiveViewSelBgColor Attribute
// SelTextColor is applied to the text of the selected line when it is
// highlighted, whether the view has the focus or not. Its attributes are
// added to those of the text, and if it has a color, that replaces the
// color of the text.
SelTextColor Attribute
// If Editable is true, keystrokes will be added to the view's internal
// buffer at the cursor position.
Editable bool
@@ -631,7 +637,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
v.FgColor, v.BgColor = ColorDefault, ColorDefault
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.InactiveViewSelBgColor, v.SelTextColor = ColorDefault, ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.buf.ei.screenColMax = v.InnerWidth()
return v
@@ -722,12 +728,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
}
if y >= rangeSelectStart && y <= rangeSelectEnd {
// this ensures we use the bright variant of a colour upon highlight
fgColorComponent := fgColor & ^AttrAll
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
fgColor += 8
}
fgColor = fgColor | AttrBold
fgColor = applySelTextColor(fgColor, v.SelTextColor)
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
} else {
@@ -1843,6 +1844,15 @@ func indexFunc(r rune) bool {
return r == ' ' || r == 0
}
// applySelTextColor adds the attributes of selTextColor to fgColor, and
// replaces the color of fgColor with that of selTextColor if it has one.
func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
if selTextColor&AttrColorBits != ColorDefault {
fgColor = fgColor&AttrStyleBits | selTextColor&AttrColorBits
}
return fgColor | selTextColor&AttrStyleBits
}
// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
+40
View File
@@ -780,3 +780,43 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
"trailing cell at (%d, 2) should have green bg", x)
}
}
func TestApplySelTextColor(t *testing.T) {
scenarios := []struct {
name string
fgColor Attribute
selTextColor Attribute
expected Attribute
}{
{
name: "adds attributes",
fgColor: ColorRed | AttrUnderline,
selTextColor: AttrBold,
expected: ColorRed | AttrUnderline | AttrBold,
},
{
name: "replaces the color",
fgColor: ColorRed | AttrUnderline,
selTextColor: ColorWhite | AttrBold,
expected: ColorWhite | AttrUnderline | AttrBold,
},
{
name: "replaces an RGB color",
fgColor: NewRGBColor(0x12, 0x34, 0x56),
selTextColor: ColorBlue,
expected: ColorBlue,
},
{
name: "leaves the text alone if default",
fgColor: ColorRed | AttrUnderline,
selTextColor: ColorDefault,
expected: ColorRed | AttrUnderline,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, applySelTextColor(s.fgColor, s.selTextColor))
})
}
}
+16 -48
View File
@@ -7,7 +7,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
@@ -144,8 +143,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
},
{
Keys: opts.GetKeys(opts.Config.Branches.FastForward),
Handler: self.withItem(self.fastForward),
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
Handler: self.withItems(self.fastForward),
GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
Description: self.c.Tr.FastForward,
Tooltip: self.c.Tr.FastForwardTooltip,
},
@@ -206,13 +205,13 @@ func (self *BranchesController) GetOnRenderToMain() func() {
} else {
cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName())
ptyTask := types.NewRunPtyTask(cmdObj.GetCmd())
task = ptyTask
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
task = rendererTask
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
rendererTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
rendererTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
}
}
@@ -655,54 +654,23 @@ func (self *BranchesController) rebase(branch *models.Branch) error {
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name)
}
func (self *BranchesController) fastForward(branch *models.Branch) error {
if !branch.IsTrackingRemote() {
func (self *BranchesController) fastForward(branches []*models.Branch) error {
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() }) {
return errors.New(self.c.Tr.FwdNoUpstream)
}
if !branch.RemoteBranchStoredLocally() {
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.RemoteBranchStoredLocally() }) {
return errors.New(self.c.Tr.FwdNoLocalUpstream)
}
if branch.IsAheadForPull() {
// A branch that is only ahead has nothing to fast-forward to. One that is
// both ahead and behind may still be reset to its upstream, so let the
// helper look into it.
if lo.SomeBy(branches, func(branch *models.Branch) bool {
return branch.IsAheadForPull() && !branch.IsBehindForPull()
}) {
return errors.New(self.c.Tr.FwdCommitsToPush)
}
action := self.c.Tr.Actions.FastForwardBranch
worktree, ok := self.worktreeForBranch(branch)
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
if ok {
self.c.LogAction(action)
worktreeGitDir := ""
worktreePath := ""
// if it is the current worktree path, no need to specify the path
if !worktree.IsCurrent {
worktreeGitDir = worktree.GitDir
worktreePath = worktree.Path
}
err := self.c.Git().Sync.Pull(
task,
git_commands.PullOptions{
RemoteName: branch.UpstreamRemote,
BranchName: branch.UpstreamBranch,
FastForwardOnly: true,
WorktreeGitDir: worktreeGitDir,
WorktreePath: worktreePath,
},
)
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
}
self.c.LogAction(action)
err := self.c.Git().Sync.FastForward(
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
)
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return err
})
return self.c.Helpers().BranchesHelper.FastForwardBranches(branches)
}
func (self *BranchesController) createTag(branch *models.Branch) error {
@@ -177,7 +177,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
+3 -3
View File
@@ -356,7 +356,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
prefix += self.c.Tr.MergeConflictCurrentDiff
}
prefix += "\n\n"
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
self.renderToMainWithTask(types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix))
return
}
@@ -378,7 +378,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
@@ -395,7 +395,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
}
}
+1 -8
View File
@@ -3,7 +3,6 @@ package controllers
import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -185,13 +184,7 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
// takes effect, and shows a toast naming it.
func (self *GlobalController) onDiffRenderersChanged() {
currentSide := self.c.Context().CurrentSide()
currentKey := self.c.Context().Current().GetKey()
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
currentSide.HandleRenderToMain()
}
self.c.Helpers().Diff.RenderToMainAgain()
diffRendererConfigManager := self.c.State().GetDiffRendererConfigManager()
current, total := diffRendererConfigManager.CurrentDiffRendererIndex()
@@ -0,0 +1,39 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
)
// Returns the branches stacked below the given branch: those whose tip is one
// of the given commits of the branch that hasn't been merged into a main
// branch yet. The branch itself and the main branches are left out. The
// result is in the order in which the tips appear in commits, so the branch
// closest to the given one comes first.
//
// Only the commits passed in are looked at, so a branch whose tip is further
// down the history than the loaded commits is not found.
func BranchesBelowInStack(
commits []*models.Commit,
branches []*models.Branch,
branch *models.Branch,
mainBranches []string,
) []*models.Branch {
branchesByTip := map[string][]*models.Branch{}
for _, other := range branches {
if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) {
continue
}
branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other)
}
result := []*models.Branch{}
for _, commit := range commits {
if commit.IsTODO() || commit.Status == models.StatusMerged {
continue
}
result = append(result, branchesByTip[commit.Hash()]...)
}
return result
}
@@ -0,0 +1,108 @@
package helpers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestBranchesBelowInStack(t *testing.T) {
hashPool := &utils.StringPool{}
commit := func(hash string, status models.CommitStatus) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status})
}
todoCommit := func(hash string) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing})
}
branch := func(name string, tip string) *models.Branch {
return &models.Branch{Name: name, CommitHash: tip}
}
current := branch("current", "c3")
mainBranches := []string{"master", "main"}
scenarios := []struct {
testName string
commits []*models.Commit
branches []*models.Branch
expected []string
}{
{
testName: "stack of branches, closest to the current one first",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c2", models.StatusPushed),
commit("c1", models.StatusUnpushed),
commit("m1", models.StatusMerged),
},
branches: []*models.Branch{current, branch("first", "c1"), branch("second", "c2"), branch("master", "m1")},
expected: []string{"second", "first"},
},
{
testName: "branch pointing at the same commit as the current one",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("m1", models.StatusMerged),
},
branches: []*models.Branch{current, branch("twin", "c3")},
expected: []string{"twin"},
},
{
testName: "several branches pointing at one commit keep their order",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c1", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("one", "c1"), branch("two", "c1")},
expected: []string{"one", "two"},
},
{
testName: "main branches are left out even if their tip isn't merged",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c1", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("main", "c1")},
expected: []string{},
},
{
testName: "branches whose tip is merged are left out",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("m1", models.StatusMerged),
commit("m0", models.StatusMerged),
},
branches: []*models.Branch{current, branch("old", "m0"), branch("master", "m1")},
expected: []string{},
},
{
testName: "branches whose tip is not among the commits are left out",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("sibling", "s1")},
expected: []string{},
},
{
testName: "todo commits of a rebase don't count",
commits: []*models.Commit{
todoCommit("t1"),
commit("c3", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("other", "t1")},
expected: []string{},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches)
names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name })
assert.Equal(t, s.expected, names)
})
}
}
+263 -26
View File
@@ -5,9 +5,11 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@@ -420,52 +422,287 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe
if self.c.State().GetRepoGeneration() != fetchGeneration {
return nil
}
err := self.AutoForwardBranches(background)
if background && err != nil {
// The background poller discards this return value, so surface
// the error in the log rather than as a popup for background work.
self.c.Log.Error(err)
return nil
}
return err
self.AutoForwardBranches(background)
return nil
},
})
return fetchErr
}
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
// One of the branches that a fast-forward is about to bring to its upstream
type branchToForward struct {
branch *models.Branch
// the worktree that the branch is checked out in, nil if there is none
worktree *models.Worktree
// whether the branch has to be reset to its upstream because it has
// diverged from it, as opposed to being moved forward
reset bool
}
// Updates the given branches to their upstream branches, fetching those first.
// A branch that is behind its upstream is moved forward to it; one that has
// diverged from it is reset to it, as long as it has no commits of its own. If
// any of the branches can't be updated, none of them is.
func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error {
fastForward, err := self.PrepareFastForward(branches)
if err != nil {
return err
}
return self.WithInlineStatusOnBranches(branches, types.ItemOperationFastForwarding, fastForward)
}
// Does the part of FastForwardBranches that looks at the model, and so has to
// run on the UI thread. Returns the rest, which is to be run on a worker; this
// lets other operations include the fast-forward in their own task.
func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func(gocui.Task) error, error) {
toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward {
worktree, _ := self.worktreeForBranch(branch)
return &branchToForward{branch: branch, worktree: worktree}
})
anyCheckedOut := lo.SomeBy(toForward, func(f *branchToForward) bool { return f.worktree != nil })
// Updating that worktree would move its detached HEAD, which belongs to the
// rebase or bisect, and leave the branch alone
for _, f := range toForward {
if f.worktree != nil && f.worktree.IsRebasingOrBisecting {
return nil, errors.New(utils.ResolvePlaceholderString(
self.c.Tr.FwdBranchRebasingOrBisecting,
map[string]string{"branchName": f.branch.Name, "worktreeName": f.worktree.Name},
))
}
}
return func(task gocui.Task) error {
defer func() {
if anyCheckedOut {
// The files of those worktrees have changed as well
self.c.RefreshFromWorker(types.RefreshOptions{})
} else {
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
}
}()
self.c.LogAction(self.c.Tr.Actions.FastForwardBranch)
if err := self.fetchUpstreamBranches(task, branches); err != nil {
return err
}
// Look at all the branches before moving any of them, so that one we
// have to refuse leaves the others alone too
for _, f := range toForward {
if err := self.planForwardingBranch(f); err != nil {
return err
}
}
return self.forwardBranches(self.c.Git(), toForward)
}, nil
}
// Runs f on a worker with all the given branches shown as being in the given
// operation while it runs
func (self *BranchesHelper) WithInlineStatusOnBranches(branches []*models.Branch, operation types.ItemOperation, f func(gocui.Task) error) error {
return WithInlineStatusOnItems(self.c, branches, operation, context.LOCAL_BRANCHES_CONTEXT_KEY, f)
}
func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error {
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string {
return branch.UpstreamRemote
}))
for _, remote := range remotes {
remoteBranches := []string{}
for _, branch := range branches {
if branch.UpstreamRemote == remote {
remoteBranches = append(remoteBranches, branch.UpstreamBranch)
}
}
if err := self.c.Git().Sync.FetchRemoteBranches(task, remote, remoteBranches); err != nil {
return err
}
}
return nil
}
// Works out whether the branch has to be reset to its upstream, and returns an
// error if it can't be brought there at all.
func (self *BranchesHelper) planForwardingBranch(f *branchToForward) error {
f.reset = !self.c.Git().Branch.IsAncestor(
f.branch.FullRefName(), f.branch.FullUpstreamRefName())
if !f.reset {
return nil
}
// Moving the branch to its upstream means giving up the commits it is
// ahead by, so make sure that none of them is ours
hasLocalOnlyCommits, err := self.c.Git().Branch.HasLocalOnlyCommits(f.branch)
if err != nil {
return err
}
if hasLocalOnlyCommits {
return errors.New(utils.ResolvePlaceholderString(
self.c.Tr.FwdLocalOnlyCommits,
map[string]string{"branchName": f.branch.Name},
))
}
if f.worktree != nil {
// Resetting the branch changes the files of the worktree under the
// user's feet, so only do it while they have no changes of their own
// there
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
if err != nil {
return err
}
if hasChanges {
return errors.New(utils.ResolvePlaceholderString(
self.c.Tr.FwdUncommittedChanges,
map[string]string{"branchName": f.branch.Name},
))
}
}
return nil
}
// Keeps going when a branch fails to update, so that it doesn't hold up the
// others, and returns the errors of all the failed ones.
func (self *BranchesHelper) forwardBranches(git *commands.GitCommand, toForward []*branchToForward) error {
var errs []error
// The branches that aren't checked out anywhere are nothing but refs to
// update, so they can all be done in one go
updateCommands := ""
for _, f := range toForward {
if f.worktree == nil {
updateCommands += fmt.Sprintf("update %s %s %s\n",
f.branch.FullRefName(), f.branch.FullUpstreamRefName(), f.branch.CommitHash)
}
}
if updateCommands != "" {
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
if err := git.Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil {
errs = append(errs, err)
}
}
// A branch that is checked out somewhere needs the files of that worktree
// to be updated along with it
for _, f := range toForward {
if f.worktree == nil {
continue
}
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
var err error
if f.reset {
err = git.WorkingTree.ResetKeep(
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
} else {
err = git.Branch.FastForwardMerge(
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
}
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Returns the git dir and the path to pass for the given worktree; both are
// empty for the current one, which git commands use by default anyway.
func (self *BranchesHelper) worktreeArgs(worktree *models.Worktree) (string, string) {
if worktree.IsCurrent {
return "", ""
}
return worktree.GitDir, worktree.Path
}
// Reads the branches from the model, so it must be called on the UI thread; the
// git work happens on a worker.
func (self *BranchesHelper) AutoForwardBranches(background bool) {
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
return
}
branches := self.c.Model().Branches
if len(branches) == 0 {
return nil
return
}
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
updateCommands := ""
toForward := []*branchToForward{}
// The first branch is the currently checked out branch; skip it
for _, branch := range branches[1:] {
if branch.RemoteBranchStoredLocally() &&
!self.checkedOutByOtherWorktree(branch) &&
(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
if isStrictlyBehind {
updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash)
}
if !branch.RemoteBranchStoredLocally() ||
!(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
continue
}
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
if !isStrictlyBehind {
continue
}
// Changing the files of the current worktree without being asked to
// would be surprising. A worktree that is mid-rebase or mid-bisect has
// its HEAD detached from the branch, so the branch can't be moved
// there, and neither can it in a worktree whose directory is missing.
worktree, _ := self.worktreeForBranch(branch)
if worktree != nil && (worktree.IsCurrent || worktree.IsRebasingOrBisecting || worktree.IsPathMissing) {
continue
}
toForward = append(toForward, &branchToForward{branch: branch, worktree: worktree})
}
if updateCommands == "" {
return nil
if len(toForward) == 0 {
return
}
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
// A background worker doesn't block switching repos, so it has to stick
// to the git commands of the repo that the branches came from
git := self.c.Git()
onWorker := lo.Ternary(background, self.c.OnWorkerBackground, self.c.OnWorker)
onWorker(func(gocui.Task) error {
// Only change the files of another worktree while the user has no
// changes of their own there
toForward := lo.Filter(toForward, func(f *branchToForward, _ int) bool {
if f.worktree == nil {
return true
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
hasChanges, err := git.WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
if err != nil {
self.c.Log.Errorf("Failed to check worktree %s for changes: %v", f.worktree.Name, err)
}
return err == nil && !hasChanges
})
if len(toForward) == 0 {
return nil
}
return err
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
err := self.forwardBranches(git, toForward)
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
if background && err != nil {
// Surface the error in the log rather than as a popup for background
// work
self.c.Log.Error(err)
return nil
}
return err
})
}
+16 -3
View File
@@ -74,11 +74,11 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
}
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
return types.NewRunPtyTask(cmdObj.GetCmd())
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
}
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
@@ -98,6 +98,19 @@ func (self *DiffHelper) ExitDiffMode() error {
return nil
}
// RenderToMainAgain renders the current side panel into the main view again, if
// that is what the main view shows. This is for when something that the
// rendering depends on has changed, such as the diff renderer.
func (self *DiffHelper) RenderToMainAgain() {
currentSide := self.c.Context().CurrentSide()
currentKey := self.c.Context().Current().GetKey()
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
currentSide.HandleRenderToMain()
}
}
func (self *DiffHelper) RenderDiff() {
args := self.DiffArgs()
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
@@ -106,7 +119,7 @@ func (self *DiffHelper) RenderDiff() {
self.c.Tr.ShowingGitDiff,
"git diff "+strings.Join(args, " "),
)
task := types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
@@ -94,6 +94,23 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g
}
}
// Runs f on a worker with all the given items shown as being in the given
// operation while it runs
func WithInlineStatusOnItems[T types.HasUrn](c *HelperCommon, items []T, operation types.ItemOperation, contextKey types.ContextKey, f func(gocui.Task) error) error {
return c.WithInlineStatus(items[0], operation, contextKey, func(task gocui.Task) error {
for _, item := range items[1:] {
c.State().SetItemOperation(item, operation)
}
defer func() {
for _, item := range items[1:] {
c.State().ClearItemOperation(item)
}
}()
return f(task)
})
}
func (self *InlineStatusHelper) start(opts InlineStatusOpts) {
self.c.State().SetItemOperation(opts.Item, opts.Operation)
@@ -525,7 +525,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
}
wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference()
canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName)
canFastForward := self.c.Git().Branch.IsAncestor("HEAD", refName)
var firstRegularMergeItem *types.MenuItem
var secondRegularMergeItem *types.MenuItem
@@ -1150,14 +1150,14 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
// self.refreshStatus is called at the end of this because that's when we can
// be sure there is a State.Model.Branches array to pick the current branch from
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadExtraInfo bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
loadSeq := self.branchLoadSeq.Add(1)
branches, err := env.git.Loaders.BranchLoader.Load(
reflogCommits,
captured.mainBranches,
captured.oldBranches,
loadBehindCounts,
loadExtraInfo,
func(f func() error) {
self.onWorker(env.background, func(_ gocui.Task) error {
err := f()
@@ -47,7 +47,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
} else {
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
task = types.NewRunPtyTask(cmdObj.GetCmd())
task = types.NewRunDiffRendererTask(cmdObj.GetCmd())
}
self.c.RenderToMainViews(types.RefreshMainOpts{
+1 -1
View File
@@ -93,7 +93,7 @@ func (self *StashController) GetOnRenderToMain() func() {
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
} else {
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
task = types.NewRunPtyTaskWithPrefix(
task = types.NewRunDiffRendererTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
prefix,
)
+1 -1
View File
@@ -146,7 +146,7 @@ func (self *StatusController) editConfig() error {
func (self *StatusController) showAllBranchLogs() {
cmdObj := self.c.Git().Branch.AllBranchesLogCmdObj()
task := types.NewRunPtyTask(cmdObj.GetCmd())
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
title := self.c.Tr.LogTitle
if i, n := self.c.Git().Branch.GetAllBranchesLogIdxAndCount(); n > 1 {
+280 -16
View File
@@ -8,9 +8,11 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type SyncController struct {
@@ -87,18 +89,140 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func(
}
func (self *SyncController) push(currentBranch *models.Branch) error {
// if we are behind our upstream branch we'll ask if the user wants to force push
if currentBranch.IsTrackingRemote() {
opts := pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()}
branchesBelow := self.unpushedBranchesBelow(currentBranch)
if len(branchesBelow) == 0 {
return self.pushCurrentBranch(currentBranch)
}
branchName := map[string]string{"branchName": currentBranch.Name}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Push,
Prompt: self.branchesBelowPrompt(currentBranch, branchesBelow),
Items: []*types.MenuItem{
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PushBranchAndBranchesBelow, branchName),
OnPress: func() error {
return self.pushWithBranchesBelow(currentBranch, branchesBelow)
},
},
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PushOnlyCurrentBranch, branchName),
OnPress: func() error {
return self.pushCurrentBranch(currentBranch)
},
},
},
})
}
func (self *SyncController) pushCurrentBranch(currentBranch *models.Branch) error {
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
// if we are behind our upstream branch we'll ask if the user wants to force push
if currentBranch.IsBehindForPush() {
return self.requestToForcePush(currentBranch, opts)
}
return self.pushAux(currentBranch, opts)
})
}
// The branches stacked below the current one that have commits to push
func (self *SyncController) unpushedBranchesBelow(currentBranch *models.Branch) []*models.Branch {
branchesBelow := helpers.BranchesBelowInStack(
self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches)
return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool {
// Pushing a branch whose remote branch was deleted would recreate it.
// A branch that is only behind its remote branch has nothing to push,
// and force-pushing it would move the remote branch back to an older
// commit.
return branch.PushRemote != "" && !branch.UpstreamGone && branch.IsAheadForPush()
})
}
func (self *SyncController) branchesBelowPrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string {
intro := utils.ResolvePlaceholderString(
self.c.Tr.BranchesBelowHaveCommitsToPush,
map[string]string{"branchName": currentBranch.Name},
)
lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string {
divergence := "↑" + branch.AheadForPush
if branch.IsBehindForPush() {
divergence = "↓" + branch.BehindForPush + divergence
}
return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence))
})
return intro + "\n\n " + strings.Join(lines, "\n ")
}
// Pushes the current branch and the branches stacked below it, after asking
// for confirmation if any of them has to be force-pushed
func (self *SyncController) pushWithBranchesBelow(currentBranch *models.Branch, branchesBelow []*models.Branch) error {
if currentBranch.RemoteBranchStoredLocally() && currentBranch.PushRemote != "" {
// We know where the current branch goes and whether it needs to be
// forced, so it is pushed like the branches below it, in the same
// command as those that go to the same remote
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
return self.confirmForcePushIfNeeded(branches, func(forceWithLease bool) error {
return self.pushBranchesAux(currentBranch, branchesBelow, forceWithLease)
})
}
// The current branch has no upstream yet, or its remote branch isn't
// stored locally. Push it the way it is pushed on its own, and the branches
// below it after it.
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
return self.confirmForcePushIfNeeded(branchesBelow, func(forceWithLease bool) error {
opts.branchesBelow = branchesBelow
opts.forceWithLeaseBelow = forceWithLease
return self.pushAux(currentBranch, opts)
})
})
}
// Calls push right away if none of the branches has diverged from its remote
// branch, and after the user confirmed force-pushing otherwise
func (self *SyncController) confirmForcePushIfNeeded(branches []*models.Branch, push func(forceWithLease bool) error) error {
diverged := lo.Filter(branches, func(branch *models.Branch, _ int) bool {
return branch.IsBehindForPush()
})
if len(diverged) == 0 {
return push(false)
}
if self.c.UserConfig().Git.DisableForcePushing {
return errors.New(self.c.Tr.ForcePushBranchesDisabled)
}
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.ForcePush,
Prompt: utils.ResolvePlaceholderString(
self.c.Tr.ForcePushBranchesPrompt,
map[string]string{
"branches": " " + strings.Join(lo.Map(diverged, func(branch *models.Branch, _ int) string { return branch.Name }), "\n "),
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
},
),
HandleConfirm: func() error {
return push(true)
},
})
return nil
}
// Works out where the current branch is pushed to: to its upstream, to a
// branch of the same name if push.default is "current", or to an upstream the
// user enters in a prompt. Calls onResolved with the options for that push.
func (self *SyncController) resolvePushOfCurrentBranch(currentBranch *models.Branch, onResolved func(pushOpts) error) error {
if currentBranch.IsTrackingRemote() {
return onResolved(pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()})
}
if self.c.Git().Config.GetPushToCurrent() {
return self.pushAux(currentBranch, pushOpts{setUpstream: true})
return onResolved(pushOpts{setUpstream: true})
}
return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
@@ -107,7 +231,7 @@ func (self *SyncController) push(currentBranch *models.Branch) error {
return err
}
return self.pushAux(currentBranch, pushOpts{
return onResolved(pushOpts{
setUpstream: true,
upstreamRemote: upstreamRemote,
upstreamBranch: upstreamBranch,
@@ -116,7 +240,36 @@ func (self *SyncController) push(currentBranch *models.Branch) error {
}
func (self *SyncController) pull(currentBranch *models.Branch) error {
action := self.c.Tr.Actions.Pull
branchesBelow := self.updatableBranchesBelow(currentBranch)
if len(branchesBelow) == 0 {
return self.pullCurrentBranch(currentBranch, nil)
}
branchName := map[string]string{"branchName": currentBranch.Name}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Pull,
Prompt: self.branchesBelowToUpdatePrompt(currentBranch, branchesBelow),
Items: []*types.MenuItem{
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PullBranchAndBranchesBelow, branchName),
OnPress: func() error {
return self.pullCurrentBranch(currentBranch, branchesBelow)
},
},
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PullOnlyCurrentBranch, branchName),
OnPress: func() error {
return self.pullCurrentBranch(currentBranch, nil)
},
},
},
})
}
// Pulls the current branch, after updating the given branches stacked below it
// to their upstream branches
func (self *SyncController) pullCurrentBranch(currentBranch *models.Branch, branchesBelow []*models.Branch) error {
opts := PullFilesOptions{Action: self.c.Tr.Actions.Pull, BranchesBelow: branchesBelow}
// if we have no upstream branch we need to set that first
if !currentBranch.IsTrackingRemote() {
@@ -125,11 +278,46 @@ func (self *SyncController) pull(currentBranch *models.Branch) error {
return err
}
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
return self.PullAux(currentBranch, opts)
})
}
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
return self.PullAux(currentBranch, opts)
}
// The branches stacked below the current one that can be updated to their
// upstream branches without losing anything: those that are behind them, and
// those that diverged from them only because they were rewritten
func (self *SyncController) updatableBranchesBelow(currentBranch *models.Branch) []*models.Branch {
branchesBelow := helpers.BranchesBelowInStack(
self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches)
return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool {
// Updating a branch that is checked out in another worktree changes
// the files there, which is more than pulling the current branch
// should do
if git_commands.CheckedOutByOtherWorktree(branch, self.c.Model().Worktrees) {
return false
}
return branch.IsBehindForPull() && (!branch.IsAheadForPull() || branch.UpstreamRewritten.Load())
})
}
func (self *SyncController) branchesBelowToUpdatePrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string {
intro := utils.ResolvePlaceholderString(
self.c.Tr.BranchesBelowHaveChangedOnRemote,
map[string]string{"branchName": currentBranch.Name},
)
lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string {
divergence := "↓" + branch.BehindForPull
if branch.IsAheadForPull() {
divergence += "↑" + branch.AheadForPull
}
return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence))
})
return intro + "\n\n " + strings.Join(lines, "\n ")
}
func (self *SyncController) setCurrentBranchUpstream(upstream string) error {
@@ -155,10 +343,35 @@ type PullFilesOptions struct {
UpstreamBranch string
FastForwardOnly bool
Action string
// Branches stacked below the current one, updated to their upstream
// branches before the current one is pulled
BranchesBelow []*models.Branch
}
func (self *SyncController) PullAux(currentBranch *models.Branch, opts PullFilesOptions) error {
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPulling, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
var updateBranchesBelow func(gocui.Task) error
if len(opts.BranchesBelow) > 0 {
var err error
updateBranchesBelow, err = self.c.Helpers().BranchesHelper.PrepareFastForward(opts.BranchesBelow)
if err != nil {
return err
}
}
branches := append([]*models.Branch{currentBranch}, opts.BranchesBelow...)
return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPulling, func(task gocui.Task) error {
// Update the branches below first. If one of them pointed into the
// commits that a rebasing pull rebases, the pull would move it when
// rebase.updateRefs is set, and updating it afterwards would fail. If
// updating them fails, the current branch isn't pulled either; the
// user can still choose to pull only the current branch.
if updateBranchesBelow != nil {
if err := updateBranchesBelow(task); err != nil {
return err
}
}
return self.pullWithLock(task, opts)
})
}
@@ -190,20 +403,28 @@ type pushOpts struct {
// the server rejected. If this is true, we don't offer to force-push if the
// server rejected, but rather ask the user to fetch.
remoteBranchStoredLocally bool
// Branches stacked below the current one, pushed after it with one command
// per remote, with --force-with-lease if forceWithLeaseBelow is set. The
// force options above apply to the current branch's own push only.
branchesBelow []*models.Branch
forceWithLeaseBelow bool
}
func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error {
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.Push)
return self.withPushingStatus(currentBranch, opts.branchesBelow, func(task gocui.Task) error {
refspecs := []string{}
if opts.upstreamBranch != "" {
refspecs = append(refspecs, fmt.Sprintf("refs/heads/%s:%s", currentBranch.Name, opts.upstreamBranch))
}
err := self.c.Git().Sync.Push(
task,
git_commands.PushOpts{
Force: opts.force,
ForceWithLease: opts.forceWithLease,
CurrentBranch: currentBranch.Name,
UpstreamRemote: opts.upstreamRemote,
UpstreamBranch: opts.upstreamBranch,
SetUpstream: opts.setUpstream,
Remote: opts.upstreamRemote,
Refspecs: refspecs,
})
if err != nil {
if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") {
@@ -229,11 +450,54 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts)
}
return err
}
err = self.pushBranches(task, opts.branchesBelow, opts.forceWithLeaseBelow)
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
return err
})
}
// Pushes the current branch along with the branches stacked below it, all of
// them with explicit refspecs
func (self *SyncController) pushBranchesAux(currentBranch *models.Branch, branchesBelow []*models.Branch, forceWithLease bool) error {
return self.withPushingStatus(currentBranch, branchesBelow, func(task gocui.Task) error {
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
err := self.pushBranches(task, branches, forceWithLease)
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
})
}
// Runs f as a push of the current branch, showing it and the other branches
// as being pushed while it runs
func (self *SyncController) withPushingStatus(currentBranch *models.Branch, otherBranches []*models.Branch, f func(gocui.Task) error) error {
branches := append([]*models.Branch{currentBranch}, otherBranches...)
return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPushing, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.Push)
return f(task)
})
}
// Pushes the branches to their push destinations, one command per remote
func (self *SyncController) pushBranches(task gocui.Task, branches []*models.Branch, forceWithLease bool) error {
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.PushRemote }))
for _, remote := range remotes {
refspecs := lo.FilterMap(branches, func(branch *models.Branch, _ int) (string, bool) {
return fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch.Name, branch.PushBranch), branch.PushRemote == remote
})
err := self.c.Git().Sync.Push(task, git_commands.PushOpts{
ForceWithLease: forceWithLease,
Remote: remote,
Refspecs: refspecs,
})
if err != nil {
return err
}
}
return nil
}
func (self *SyncController) requestToForcePush(currentBranch *models.Branch, opts pushOpts) error {
forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing
if forcePushDisabled {
+117 -105
View File
@@ -7,6 +7,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -61,17 +62,17 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.withItem(self.delete),
Handler: self.withItems(self.delete),
Description: self.c.Tr.Delete,
GetDisabledReason: self.require(self.singleItemSelected()),
GetDisabledReason: self.require(self.itemsSelected()),
Tooltip: self.c.Tr.TagDeleteTooltip,
OpensMenu: true,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Branches.PushTag),
Handler: self.withItem(self.push),
GetDisabledReason: self.require(self.singleItemSelected()),
Handler: self.withItems(self.push),
GetDisabledReason: self.require(self.itemsSelected()),
Description: self.c.Tr.PushTag,
Tooltip: self.c.Tr.PushTagTooltip,
DisplayOnScreen: true,
@@ -164,54 +165,104 @@ func (self *TagsController) checkout(tag *models.Tag) error {
return nil
}
func (self *TagsController) localDelete(tag *models.Tag) error {
func (self *TagsController) localDelete(tags []*models.Tag) error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
err := self.c.Git().Tag.LocalDelete(tag.Name)
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
err := self.c.Git().Tag.LocalDelete(tagNames(tags))
self.refreshAfterLocalDelete()
return err
})
}
func (self *TagsController) remoteDelete(tag *models.Tag) error {
title := utils.ResolvePlaceholderString(
self.c.Tr.SelectRemoteTagUpstream,
map[string]string{
"tagName": tag.Name,
func (self *TagsController) remoteDelete(tags []*models.Tag) error {
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTagsPrompt, self.c.Tr.DeleteRemoteTagPrompt)
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
return err
}
self.c.Toast(lo.Ternary(len(tags) > 1, self.c.Tr.RemoteTagsDeletedMessage, self.c.Tr.RemoteTagDeletedMessage))
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
}
func (self *TagsController) localAndRemoteDelete(tags []*models.Tag) error {
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTagsPrompt, self.c.Tr.DeleteLocalAndRemoteTagPrompt)
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
if err := self.c.Git().Tag.LocalDelete(tagNames(tags)); err != nil {
return err
}
self.refreshAfterLocalDelete()
return nil
})
}
// Refreshes after deleting local tags. If the tags were a range selection,
// this also collapses it to its first line; otherwise it would select the tags
// that moved up into the place of the deleted ones. Collapsing it in the Then
// of a batched refresh draws the shorter list and the new selection in the
// same frame.
func (self *TagsController) refreshAfterLocalDelete() {
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMITS, types.TAGS},
BatchUIUpdates: true,
Then: func() error {
if self.context().IsSelectingRange() {
self.context().CollapseRangeSelectionToTop()
self.c.PostRefreshUpdate(self.context())
}
return nil
},
)
})
}
// Asks for the remote to delete the tags from and for a confirmation, and then
// runs deleteTags on a worker with the tags shown as being deleted.
// confirmPromptTemplate can use the placeholder upstream, and tagName if there
// is only one tag.
func (self *TagsController) confirmRemoteDelete(
tags []*models.Tag,
confirmPromptTemplate string,
deleteTags func(task gocui.Task, upstream string) error,
) error {
var title string
if len(tags) == 1 {
title = utils.ResolvePlaceholderString(
self.c.Tr.SelectRemoteTagUpstream,
map[string]string{
"tagName": tags[0].Name,
},
)
} else {
title = self.c.Tr.SelectRemoteTagsUpstream
}
self.c.Prompt(types.PromptOpts{
Title: title,
InitialContent: "origin",
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
HandleConfirm: func(upstream string) error {
confirmTitle := utils.ResolvePlaceholderString(
self.c.Tr.DeleteTagTitle,
map[string]string{
"tagName": tag.Name,
},
)
confirmPrompt := utils.ResolvePlaceholderString(
self.c.Tr.DeleteRemoteTagPrompt,
confirmPromptTemplate,
map[string]string{
"tagName": tag.Name,
"tagName": tags[0].Name,
"upstream": upstream,
},
)
self.c.Confirm(types.ConfirmOpts{
Title: confirmTitle,
Title: self.deleteTagsTitle(tags),
Prompt: confirmPrompt,
HandleConfirm: func() error {
return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil {
return err
}
self.c.Toast(self.c.Tr.RemoteTagDeletedMessage)
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
return deleteTags(task, upstream)
})
},
})
@@ -223,116 +274,73 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error {
return nil
}
func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error {
title := utils.ResolvePlaceholderString(
self.c.Tr.SelectRemoteTagUpstream,
map[string]string{
"tagName": tag.Name,
},
)
self.c.Prompt(types.PromptOpts{
Title: title,
InitialContent: "origin",
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
HandleConfirm: func(upstream string) error {
confirmTitle := utils.ResolvePlaceholderString(
self.c.Tr.DeleteTagTitle,
map[string]string{
"tagName": tag.Name,
},
)
confirmPrompt := utils.ResolvePlaceholderString(
self.c.Tr.DeleteLocalAndRemoteTagPrompt,
map[string]string{
"tagName": tag.Name,
"upstream": upstream,
},
)
self.c.Confirm(types.ConfirmOpts{
Title: confirmTitle,
Prompt: confirmPrompt,
HandleConfirm: func() error {
return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
},
})
return nil
},
})
return nil
}
func (self *TagsController) delete(tag *models.Tag) error {
menuTitle := utils.ResolvePlaceholderString(
self.c.Tr.DeleteTagTitle,
map[string]string{
"tagName": tag.Name,
},
)
func (self *TagsController) delete(tags []*models.Tag) error {
menuItems := []*types.MenuItem{
{
Label: self.c.Tr.DeleteLocalTag,
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalTags, self.c.Tr.DeleteLocalTag),
Keys: menuKey('c'),
OnPress: func() error {
return self.localDelete(tag)
return self.localDelete(tags)
},
},
{
Label: self.c.Tr.DeleteRemoteTag,
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTags, self.c.Tr.DeleteRemoteTag),
Keys: menuKey('r'),
OpensMenu: true,
OnPress: func() error {
return self.remoteDelete(tag)
return self.remoteDelete(tags)
},
},
{
Label: self.c.Tr.DeleteLocalAndRemoteTag,
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTags, self.c.Tr.DeleteLocalAndRemoteTag),
Keys: menuKey('b'),
OpensMenu: true,
OnPress: func() error {
return self.localAndRemoteDelete(tag)
return self.localAndRemoteDelete(tags)
},
},
}
return self.c.Menu(types.CreateMenuOptions{
Title: menuTitle,
Title: self.deleteTagsTitle(tags),
Items: menuItems,
})
}
func (self *TagsController) push(tag *models.Tag) error {
title := utils.ResolvePlaceholderString(
self.c.Tr.PushTagTitle,
func (self *TagsController) deleteTagsTitle(tags []*models.Tag) string {
if len(tags) > 1 {
return self.c.Tr.DeleteTagsTitle
}
return utils.ResolvePlaceholderString(
self.c.Tr.DeleteTagTitle,
map[string]string{
"tagName": tag.Name,
"tagName": tags[0].Name,
},
)
}
func (self *TagsController) push(tags []*models.Tag) error {
var title string
if len(tags) == 1 {
title = utils.ResolvePlaceholderString(
self.c.Tr.PushTagTitle,
map[string]string{
"tagName": tags[0].Name,
},
)
} else {
title = self.c.Tr.PushTagsTitle
}
self.c.Prompt(types.PromptOpts{
Title: title,
InitialContent: "origin",
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
HandleConfirm: func(response string) error {
return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.PushTag)
return self.c.Git().Tag.Push(task, response, tag.Name)
return self.c.Git().Tag.Push(task, response, tagNames(tags))
})
},
})
@@ -354,3 +362,7 @@ func (self *TagsController) create() error {
func (self *TagsController) context() *context.TagsContext {
return self.c.Contexts().Tags
}
func tagNames(tags []*models.Tag) []string {
return lo.Map(tags, func(tag *models.Tag, _ int) string { return tag.Name })
}
+53 -13
View File
@@ -494,7 +494,7 @@ func (gui *Gui) onUserConfigLoaded() error {
gui.previousLanguageConfig = userConfig.Gui.Language
}
gui.setColorScheme()
gui.applyTheme()
gui.configureViewProperties()
gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return)
@@ -517,7 +517,6 @@ func (gui *Gui) onUserConfigLoaded() error {
// sake of backwards compatibility. We're making use of short circuiting here
gui.ShowExtrasWindow = userConfig.Gui.ShowCommandLog && !gui.c.GetAppState().HideCommandLog
authors.SetCustomAuthors(userConfig.Gui.AuthorColors)
if userConfig.Gui.NerdFontsVersion != "" {
icons.SetNerdFontsVersion(userConfig.Gui.NerdFontsVersion)
} else if userConfig.Gui.ShowIcons {
@@ -526,13 +525,6 @@ func (gui *Gui) onUserConfigLoaded() error {
icons.SetNerdFontsVersion("")
}
if len(userConfig.Gui.BranchColorPatterns) > 0 {
presentation.SetCustomBranches(userConfig.Gui.BranchColorPatterns, true)
} else {
// Fall back to the deprecated branchColors config
presentation.SetCustomBranches(userConfig.Gui.BranchColors, false)
}
return nil
}
@@ -942,6 +934,19 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
g.ErrorHandler = gui.PopupHandler.ErrorHandler
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
gui.applyTheme()
gui.configureViewProperties()
for _, context := range gui.c.Context().AllList() {
context.HandleRender()
}
gui.helpers.Refresh.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STATUS}})
gui.helpers.Diff.RenderToMainAgain()
return nil
})
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
!gocui.IsMouseScrollKey(key) {
@@ -1244,15 +1249,50 @@ func (gui *Gui) showBreakingChangesMessage() {
}
}
// setColorScheme sets the color scheme for the app based on the user config
func (gui *Gui) setColorScheme() {
userConfig := gui.UserConfig()
theme.UpdateTheme(userConfig.Gui.Theme)
// applyTheme sets the colors of the app from the theme in the user config,
// with the overrides for the terminal's background applied
func (gui *Gui) applyTheme() {
themeConfig := gui.UserConfig().Gui.ThemeForBackground(gui.terminalHasLightBackground(), gui.terminalBackgroundColor())
theme.UpdateTheme(themeConfig)
authors.SetCustomAuthors(themeConfig.AuthorColors)
presentation.SetCustomBranches(themeConfig.BranchColorPatterns)
gui.g.FgColor = theme.InactiveBorderColor
gui.g.SelFgColor = theme.ActiveBorderColor
gui.g.FrameColor = theme.InactiveBorderColor
gui.g.SelFrameColor = theme.ActiveBorderColor
gui.applyTerminalBackground()
}
// applyTerminalBackground tells the colors that depend on the terminal's
// background whether it is light.
func (gui *Gui) applyTerminalBackground() {
authors.SetLightBackground(gui.terminalHasLightBackground())
}
// terminalHasLightBackground goes by gui.colorScheme, or by what the terminal
// tells us if that is 'auto'.
func (gui *Gui) terminalHasLightBackground() bool {
switch gui.UserConfig().Gui.ColorScheme {
case "dark":
return false
case "light":
return true
default:
return gui.g.DetectedColorScheme().ColorScheme == gocui.ColorSchemeLight
}
}
// terminalBackgroundColor returns the background color that the terminal told
// us, as #rrggbb. It returns "" if the terminal didn't tell us, or if
// gui.colorScheme disagrees with it about whether the background is light.
func (gui *Gui) terminalBackgroundColor() string {
detected := gui.g.DetectedColorScheme()
if (detected.ColorScheme == gocui.ColorSchemeLight) != gui.terminalHasLightBackground() {
return ""
}
return detected.Background
}
func (gui *Gui) onUIThread(f func() error) {
+2 -2
View File
@@ -20,8 +20,8 @@ func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
case *types.RunCommandTask:
return gui.newCmdTask(view, v.Cmd, v.Prefix)
case *types.RunPtyTask:
return gui.newPtyTask(view, v.Cmd, v.Prefix)
case *types.RunDiffRendererTask:
return gui.newRenderTask(view, v.Cmd, v.Prefix)
}
return nil
+231
View File
@@ -0,0 +1,231 @@
package gui
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/samber/lo"
)
// renderSpec describes a render of a command's output into a view: what a way
// of running the command (see runRender) needs to know about it.
type renderSpec struct {
view *gocui.View
cmd *exec.Cmd
// The width the renderer lays its rendering out to, and the width the view
// counts its own wrapping against. Read after the layout pass, which
// settles it.
width int
// The configured stdin filter, empty unless one is configured. How git gets
// to run it depends on the way the render runs. An external diff renderer
// is named to git in the environment before the render is set up, whichever
// way it runs, so it doesn't appear here; nothing else is a command of its
// own.
stdinFilter string
}
// newRenderTask renders cmd's output into view, through the diff renderer the
// user has configured. The renderer lays its rendering out to the width of the
// view, which only the layout settles, so the task is created after it.
func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
// If we're not using a custom diff renderer, then we don't need to use a pty
return gui.newCmdTask(view, cmd, prefix)
}
// Mark the view as loading synchronously now, before the layout pass: the
// actual task is created in afterLayout (below), which runs after layout, so
// without this the next layout pass would clamp the scroll position to the
// not-yet-loaded content.
gui.getManager(view).StartLoading()
// Hold the scrollbar at its current height while the re-render loads, so the
// thumb doesn't shrink and snap back when the first partial paint swaps in
// (see the matching call in newCmdTask).
view.FreezeScrollbarHeight()
// Run the render after layout so that it gets the correct size
gui.afterLayout(func() error {
// The layout may have changed the size of the view, so only now is the
// width to render at known, and with it the renderer command.
width := view.InnerWidth()
diffRendererConfigManager := gui.stateAccessor.GetDiffRendererConfigManager()
values := config.DiffRendererValues{
Width: width,
DiffContext: gui.c.UserConfig().Git.DiffContextSize,
LightBackground: gui.terminalHasLightBackground(),
}
stdinFilter, stdinFilterErr := diffRendererConfigManager.GetStdinFilterCommand(values)
externalDiff, externalDiffErr := diffRendererConfigManager.GetExternalDiffCommand(values)
if err := errors.Join(stdinFilterErr, externalDiffErr); err != nil {
// The commands are checked with made-up values when the config is
// loaded, so only one that fails for some values and not for others
// gets here. Git's own diff is shown instead.
gui.c.ErrorToast(err.Error())
}
cmdStr := strings.Join(cmd.Args, " ")
// This communicates to diff renderers that we're in a very simple
// terminal that they should not expect to have much capabilities.
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
// Context: https://github.com/jesseduffield/lazygit/issues/3419
cmd.Env = removeExistingTermEnvVars(cmd.Env)
cmd.Env = append(cmd.Env, "TERM=dumb")
// An external diff command is named to git here, in the environment,
// because the width it renders at is only known after the layout, and
// the command's arguments were settled before it. An empty command
// means the user wants git's own diff.external config to apply, so
// leave the variable unset in that case; git takes it being set at all
// as an instruction, however little it says.
if externalDiff != "" {
cmd.Env = append(cmd.Env, "GIT_EXTERNAL_DIFF="+externalDiff)
}
spec := renderSpec{
view: view,
cmd: cmd,
width: width,
stdinFilter: stdinFilter,
}
run := gui.ptyRender
if rendersThroughAPipe() {
run = gui.pipedRender
}
return gui.newTaskForRender(spec, prefix, cmdStr, run)
})
return nil
}
// The start and onClose functions a render hands to its task: how to get the
// command running and the output reader for it, and how to tear it down again
// once the task is stopped.
type (
startRender func() (tasks.Cmd, io.Reader)
onCloseRender func()
)
// runRender is a way of running a render's command and getting at its output:
// plainly, in a pty, or through a pipe with the stdin filter as a command of our
// own. It returns the functions the task drives the command by.
type runRender func(spec renderSpec) (startRender, onCloseRender)
// newTaskForRender creates the task that reads the render's output into its
// view, running the command the given way. key names what is rendered, so that
// a re-render of the same content can be told from a render of other content.
func (gui *Gui) newTaskForRender(spec renderSpec, prefix string, key string, run runRender) error {
setColumnsEnvVar(spec.cmd, spec.width)
start, onClose := run(spec)
manager := gui.getManager(spec.view)
linesToRead := gui.linesToReadFromCmdTask(spec.view)
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), key)
}
// renderWithoutPtyEnvVar makes a render take the piped path on a platform that
// would otherwise use a pty, so that tests can exercise it anywhere.
const renderWithoutPtyEnvVar = "LAZYGIT_RENDER_WITHOUT_PTY"
// rendersThroughAPipe reports whether a render feeds the diff renderer the
// command's output through a pipe rather than running it in a pty.
//
// On Windows it has to. ConPTY doesn't pass a command's output through; it
// parses it into a screen buffer and re-encodes that for the terminal side,
// and it hands a sequence it can't represent there the moment it parses it,
// separately from the text around it. So what a renderer writes is not what
// lazygit reads. A pipe carries the bytes as the renderer wrote them.
//
// Everywhere else the pty is kept, since a renderer can read the width it
// should lay out to off it, and a configuration that doesn't name a width would
// otherwise render at whatever width the renderer falls back to.
func rendersThroughAPipe() bool {
return runtime.GOOS == "windows" || os.Getenv(renderWithoutPtyEnvVar) != ""
}
// pipedRender feeds the diff renderer the command's output through a pipe.
//
// A stdin filter becomes a command of our own here, because git only invokes
// the one named by GIT_PAGER when it thinks it is talking to a terminal, so
// with a pipe the filter would never run. An external diff renderer is git's
// own business, named in the environment and run by git per file, so with one
// the command runs alone.
func (gui *Gui) pipedRender(spec renderSpec) (startRender, onCloseRender) {
if spec.stdinFilter == "" {
return gui.plainRender(spec)
}
view := spec.view
cmd := spec.cmd
var pipe io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
// See the matching call in ptyRender for why this happens here.
view.SetContentWidth(spec.width)
// The filter runs in a plain shell, without lazygit's shell functions
// sourced, since that is the shell git would have run it in. It is
// handed git's environment for the same reason: as git's child it
// would have inherited exactly that.
pipeline, reader, err := gui.os.StartPipeline(
gui.os.Cmd.NewFromCmd(cmd).DontLog(),
gui.os.Cmd.NewShell(spec.stdinFilter, "").SetEnviron(cmd.Env).DontLog(),
)
if err != nil {
gui.c.Log.Error(err)
// The command has been started and stopped again by now, so it
// can't be run a second time without the renderer. Show what went
// wrong where the diff would have been.
return tasks.ExecCmd{Cmd: cmd}, strings.NewReader(err.Error())
}
pipe = reader
return pipeline, reader
}
onClose := func() {
// Closing the reader brings the pipeline down. The renderer's next write
// fails, so it exits, and git's write into the pipe the renderer was
// reading fails in turn.
if pipe != nil {
pipe.Close()
pipe = nil
}
}
return start, onClose
}
// setColumnsEnvVar tells a command how wide the view its output goes into is.
// git reads COLUMNS in preference to the size of the terminal it is talking to,
// and lays the diffstat graph out to it; a diff renderer with no terminal to
// ask may read it too (difftastic and diff-so-fancy do, delta does not). A
// command told nothing renders for 80 columns.
func setColumnsEnvVar(cmd *exec.Cmd, width int) {
cmd.Env = append(cmd.Env, fmt.Sprintf("COLUMNS=%d", width))
}
func removeExistingTermEnvVars(env []string) []string {
return lo.Filter(env, func(envVar string, _ int) bool {
return !isTermEnvVar(envVar)
})
}
// Terminals set a variety of different environment variables
// to identify themselves to processes. This list should catch the most common among them.
func isTermEnvVar(envVar string) bool {
return strings.HasPrefix(envVar, "TERM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
}
+77 -6
View File
@@ -21,7 +21,17 @@ type authorNameCacheKey struct {
var (
authorInitialCache = make(map[string]string)
authorNameCache = make(map[authorNameCacheKey]string)
authorStyleCache = make(map[string]*style.TextStyle)
// The styles from gui.authorColors
customAuthorStyles = make(map[string]*style.TextStyle)
// The styles derived from the names of the other authors
authorStyleCache = make(map[string]*style.TextStyle)
// Whether the terminal has a light background, for the derived styles to
// stand out against
lightBackground bool
colorsVersion int
)
const authorNameWildcard = "*"
@@ -74,12 +84,16 @@ func AuthorWithLength(authorName string, length int) string {
}
func AuthorStyle(authorName string) *style.TextStyle {
if value, ok := authorStyleCache[authorName]; ok {
if value, ok := customAuthorStyles[authorName]; ok {
return value
}
// use the unified style whatever the author name is
if value, ok := authorStyleCache[authorNameWildcard]; ok {
if value, ok := customAuthorStyles[authorNameWildcard]; ok {
return value
}
if value, ok := authorStyleCache[authorName]; ok {
return value
}
@@ -91,12 +105,42 @@ func AuthorStyle(authorName string) *style.TextStyle {
}
func trueColorStyle(str string) style.TextStyle {
hash := md5.Sum([]byte(str))
c := colorful.Hsl(randFloat(hash[0:4])*360.0, 0.6+0.4*randFloat(hash[4:8]), 0.4+randFloat(hash[8:12])*0.2)
c := colorAtPosition(ColorPosition(str))
return style.New().SetFg(style.NewRGBColor(color.RGB(uint8(c.R*255), uint8(c.G*255), uint8(c.B*255))))
}
// To check the colors at the edges of the ranges below, run
// `go run ./cmd/author_colors_repo <path>` and open the repository it creates.
func colorAtPosition(hue, saturation, lightness float64) colorful.Color {
// The lightness of an HSLuv color is how bright it looks, so every author
// comes out about equally readable whichever hue their name lands on. Plain
// HSL spreads them instead. At one and the same lightness, it gives a
// glaring yellow and a blue that all but disappears.
//
// There is one lightness range for a dark background and one for a light
// background. Each keeps every author above a contrast ratio of 4.5:1
// against common backgrounds of its kind, such as #1e1e1e and #fdf6e3.
//
// Saturation in HSLuv is a fraction of the most colorful a hue can get at
// that lightness, and pale colors are hard to tell apart, so keep it near
// the top of its range.
minLightness := 0.57
if lightBackground {
minLightness = 0.31
}
return colorful.HSLuv(hue*360.0, 0.8+0.2*saturation, minLightness+0.15*lightness)
}
// ColorPosition says where an author's color lies within the range of hues,
// saturations and lightnesses that colorAtPosition picks from. Each is a
// fraction from 0 up to 1, derived from a hash of the author's name.
func ColorPosition(authorName string) (hue, saturation, lightness float64) {
hash := md5.Sum([]byte(authorName))
return randFloat(hash[0:4]), randFloat(hash[4:8]), randFloat(hash[8:12])
}
func randFloat(hash []byte) float64 {
return float64(randInt(hash, 100)) / 100
}
@@ -128,5 +172,32 @@ func getInitials(authorName string) string {
}
func SetCustomAuthors(customAuthorColors map[string]string) {
authorStyleCache = utils.SetCustomColors(customAuthorColors)
customAuthorStyles = utils.SetCustomColors(customAuthorColors)
colorsChanged()
}
// SetLightBackground says whether the terminal has a light background, for the
// colors of authors to stand out against.
func SetLightBackground(light bool) {
if light == lightBackground {
return
}
lightBackground = light
authorStyleCache = make(map[string]*style.TextStyle)
colorsChanged()
}
// colorsChanged drops what was rendered with the previous colors of authors.
func colorsChanged() {
authorInitialCache = make(map[string]string)
authorNameCache = make(map[authorNameCacheKey]string)
colorsVersion++
}
// ColorsVersion changes whenever the colors of authors change, so that
// whatever keeps the styles of authors around can tell when they are out of
// date.
func ColorsVersion() int {
return colorsVersion
}
@@ -3,8 +3,12 @@ package authors
import (
"testing"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/lucasb-eyer/go-colorful"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)
func TestGetInitials(t *testing.T) {
@@ -41,3 +45,79 @@ func TestAuthorWithLength(t *testing.T) {
assert.Equal(t, s.expectedOutput, utils.Decolorise(AuthorWithLength(s.authorName, s.length)))
}
}
func TestAuthorColorsFollowTheConfig(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
t.Cleanup(func() { SetCustomAuthors(nil) })
SetCustomAuthors(map[string]string{"Jane Doe": "red"})
assert.Equal(t, style.FgRed.Sprint("JD"), ShortAuthor("Jane Doe"))
assert.Equal(t, style.FgRed.Sprint("Jane Doe"), LongAuthor("Jane Doe", 8))
SetCustomAuthors(map[string]string{"Jane Doe": "blue"})
assert.Equal(t, style.FgBlue.Sprint("JD"), ShortAuthor("Jane Doe"))
assert.Equal(t, style.FgBlue.Sprint("Jane Doe"), LongAuthor("Jane Doe", 8))
}
func TestSetLightBackground(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
t.Cleanup(func() {
SetLightBackground(false)
SetCustomAuthors(nil)
})
SetCustomAuthors(map[string]string{"Jane Doe": "red"})
onDarkBackground := ShortAuthor("John Smith")
SetLightBackground(true)
assert.NotEqual(t, onDarkBackground, ShortAuthor("John Smith"))
assert.Equal(t, style.FgRed.Sprint("JD"), ShortAuthor("Jane Doe"))
}
func TestAuthorColorsStandOutAgainstTheBackground(t *testing.T) {
t.Cleanup(func() { SetLightBackground(false) })
scenarios := []struct {
name string
lightBackground bool
backgrounds []string
}{
{name: "dark", lightBackground: false, backgrounds: []string{"#000000", "#1e1e1e"}},
{name: "light", lightBackground: true, backgrounds: []string{"#ffffff", "#fdf6e3"}},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
SetLightBackground(s.lightBackground)
for _, backgroundHex := range s.backgrounds {
background, err := colorful.Hex(backgroundHex)
assert.NoError(t, err)
// The edges of the range, which is where the contrast is lowest
for hue := range 100 {
for _, saturation := range []float64{0, 0.99} {
for _, lightness := range []float64{0, 0.99} {
c := colorAtPosition(float64(hue)/100, saturation, lightness)
assert.GreaterOrEqual(t, contrastRatio(c, background), 4.5,
"%s on %s", c.Hex(), backgroundHex)
}
}
}
}
})
}
}
// contrastRatio is as defined by the Web Content Accessibility Guidelines
func contrastRatio(a colorful.Color, b colorful.Color) float64 {
luminance := func(c colorful.Color) float64 {
r, g, b := c.LinearRgb()
return 0.2126*r + 0.7152*g + 0.0722*b
}
lighter := max(luminance(a), luminance(b))
darker := min(luminance(a), luminance(b))
return (lighter + 0.05) / (darker + 0.05)
}
+20 -30
View File
@@ -19,12 +19,14 @@ import (
"github.com/samber/lo"
)
type colorMatcher struct {
patterns map[string]*style.TextStyle
isRegex bool // NOTE: this value is needed only until the deprecated branchColors config is removed and only regex color patterns are used
type branchColorPattern struct {
pattern string
style style.TextStyle
}
var colorPatterns *colorMatcher
var branchColorPatterns []branchColorPattern
var dimYellow = style.FgYellow.SetDim()
func GetBranchListDisplayStrings(
branches []*models.Branch,
@@ -193,31 +195,15 @@ func getBranchDisplayStrings(
// GetBranchTextStyle branch color
func GetBranchTextStyle(name string) style.TextStyle {
if style, ok := colorPatterns.match(name); ok {
return *style
for _, p := range branchColorPatterns {
if matched, _ := regexp.MatchString(p.pattern, name); matched {
return p.style
}
}
return theme.DefaultTextColor
}
func (m *colorMatcher) match(name string) (*style.TextStyle, bool) {
if m.isRegex {
for pattern, style := range m.patterns {
if matched, _ := regexp.MatchString(pattern, name); matched {
return style, true
}
}
} else {
// old behavior using the deprecated branchColors behavior matching on branch type
branchType := strings.Split(name, "/")[0]
if value, ok := m.patterns[branchType]; ok {
return value, true
}
}
return nil, false
}
func BranchStatus(
branch *models.Branch,
itemOperation types.ItemOperation,
@@ -239,7 +225,12 @@ func BranchStatus(
} else if branch.RemoteBranchNotStoredLocally() {
result = style.FgMagenta.Sprint("?")
} else if branch.IsBehindForPull() && branch.IsAheadForPull() {
result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
// A branch that diverged only because its upstream was rewritten
// has no commits of its own, and fast-forwarding it resolves the
// divergence. Dim it to set it apart from a branch whose
// divergence needs a decision.
divergenceStyle := lo.Ternary(branch.UpstreamRewritten.Load(), dimYellow, style.FgYellow)
result = divergenceStyle.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
} else if branch.IsBehindForPull() {
result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull)
} else if branch.IsAheadForPull() {
@@ -271,11 +262,10 @@ func divergenceStr(
return result
}
func SetCustomBranches(customBranchColors map[string]string, isRegex bool) {
colorPatterns = &colorMatcher{
patterns: utils.SetCustomColors(customBranchColors),
isRegex: isRegex,
}
func SetCustomBranches(patterns config.ColorPatterns) {
branchColorPatterns = lo.Map(patterns, func(p config.ColorPattern, _ int) branchColorPattern {
return branchColorPattern{pattern: p.Pattern, style: utils.CustomColorStyle(p.Color)}
})
}
func WithPrColor(state string, text string, isBg bool) string {
+49 -1
View File
@@ -2,6 +2,7 @@ package presentation
import (
"fmt"
"slices"
"sync/atomic"
"testing"
"time"
@@ -9,10 +10,12 @@ import (
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
@@ -419,7 +422,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
defer color.ForceSetColorLevel(oldColorLevel)
c := common.NewDummyCommon()
SetCustomBranches(c.UserConfig().Gui.BranchColorPatterns, true)
SetCustomBranches(c.UserConfig().Gui.Theme.BranchColorPatterns)
for i, s := range scenarios {
icons.SetNerdFontsVersion(lo.Ternary(s.useIcons, "3", ""))
@@ -436,3 +439,48 @@ func Test_getBranchDisplayStrings(t *testing.T) {
})
}
}
func TestGetBranchTextStyle(t *testing.T) {
defer SetCustomBranches(nil)
patterns := config.ColorPatterns{
{Pattern: "^feature/", Color: "green"},
{Pattern: `ISSUE-\d+`, Color: "red"},
}
SetCustomBranches(patterns)
assert.Equal(t, style.FgGreen, GetBranchTextStyle("feature/ISSUE-1"))
assert.Equal(t, style.FgRed, GetBranchTextStyle("fix/ISSUE-1"))
assert.Equal(t, theme.DefaultTextColor, GetBranchTextStyle("main"))
slices.Reverse(patterns)
SetCustomBranches(patterns)
assert.Equal(t, style.FgRed, GetBranchTextStyle("feature/ISSUE-1"))
}
func TestBranchStatus(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
c := common.NewDummyCommon()
divergedBranch := func(upstreamRewritten bool) *models.Branch {
branch := &models.Branch{
Name: "branch",
UpstreamRemote: "origin",
UpstreamBranch: "branch",
AheadForPull: "3",
BehindForPull: "5",
}
branch.UpstreamRewritten.Store(upstreamRewritten)
return branch
}
status := func(branch *models.Branch) string {
return BranchStatus(branch, types.ItemOperationNone, c.Tr, time.Time{}, c.UserConfig())
}
assert.Equal(t, "\x1b[33m↓5↑3\x1b[0m", status(divergedBranch(false)))
assert.Equal(t, "\x1b[33;2m↓5↑3\x1b[0m", status(divergedBranch(true)),
"a branch whose upstream was rewritten should be dimmed")
}
+10 -1
View File
@@ -30,6 +30,10 @@ type pipeSetCacheKey struct {
var (
pipeSetCache = make(map[pipeSetCacheKey][][]graph.Pipe)
mutex deadlock.Mutex
// The pipes have the colors of the authors of the commits they start at,
// so they are only good for as long as these colors don't change
pipeSetCacheAuthorColors = authors.ColorsVersion()
)
type bisectBounds struct {
@@ -302,6 +306,11 @@ func indexOfFirstNonTODOCommit(commits []*models.Commit) int {
}
func loadPipesets(commits []*models.Commit) [][]graph.Pipe {
if pipeSetCacheAuthorColors != authors.ColorsVersion() {
pipeSetCache = make(map[pipeSetCacheKey][][]graph.Pipe)
pipeSetCacheAuthorColors = authors.ColorsVersion()
}
// given that our cache key is a commit hash and a commit count, it's very important that we don't actually try to render pipes
// when dealing with things like filtered commits.
cacheKey := pipeSetCacheKey{
@@ -529,7 +538,7 @@ func displayCommit(
func getBisectStatusColor(status BisectStatus) style.TextStyle {
switch status {
case BisectStatusNone:
return style.FgBlack
return theme.DefaultTextColor.SetDim()
case BisectStatusNew:
return style.FgRed
case BisectStatusOld:
+24
View File
@@ -10,6 +10,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
@@ -620,3 +623,24 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
}
}
}
func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
t.Cleanup(func() { authors.SetCustomAuthors(nil) })
hashPool := &utils.StringPool{}
commits := []*models.Commit{
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors1", AuthorName: "Jane Doe", Parents: []string{"authorcolors2"}}),
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
}
renderGraph := func() string {
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
}
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
assert.Contains(t, renderGraph(), style.FgRed.Sprint("○"))
authors.SetCustomAuthors(map[string]string{"Jane Doe": "blue"})
assert.Contains(t, renderGraph(), style.FgBlue.Sprint("○"))
}
+54 -155
View File
@@ -1,20 +1,14 @@
package gui
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
@@ -53,156 +47,61 @@ func (p ptyCmd) Wait() error { return p.wait() }
func (p ptyCmd) String() string { return p.cmd.String() }
func (p ptyCmd) Terminate() error { return oscommands.TerminateProcessGracefully(p.process) }
// Some commands need to output for a terminal to active certain behaviour.
// For example, git won't invoke the GIT_PAGER env var unless it thinks it's
// talking to a terminal. We typically write cmd outputs straight to a view,
// which is just an io.Reader. the pty package lets us wrap a command in a
// pseudo-terminal meaning we'll get the behaviour we want from the underlying
// command.
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
width := view.InnerWidth()
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
// If we're not using a custom diff renderer, then we don't need to use a pty
return gui.newCmdTask(view, cmd, prefix)
}
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
// Mark the view as loading synchronously now, before the layout pass: the
// actual task is created in afterLayout (below), which runs after layout, so
// without this the next layout pass would clamp the scroll position to the
// not-yet-loaded content.
gui.getManager(view).StartLoading()
// Hold the scrollbar at its current height while the re-render loads, so the
// thumb doesn't shrink and snap back when the first partial paint swaps in
// (see the matching call in newCmdTask).
view.FreezeScrollbarHeight()
// Run the pty after layout so that it gets the correct size
gui.afterLayout(func() error {
// Need to get the width and the pager command again because the layout might have
// changed the size of the view
width = view.InnerWidth()
pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width)
cmdStr := strings.Join(cmd.Args, " ")
// This communicates to diff renderers that we're in a very simple
// terminal that they should not expect to have much capabilities.
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
// Context: https://github.com/jesseduffield/lazygit/issues/3419
cmd.Env = removeExistingTermEnvVars(cmd.Env)
cmd.Env = append(cmd.Env, "TERM=dumb")
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)
manager := gui.getManager(view)
// Size the pty from the view's dimensions here, on the UI thread; the
// start func below runs on the task's goroutine, which must not read the
// view's live dimensions while the UI thread is laying it out.
cols, rows := gui.desiredPtySize(view)
var p oscommands.Pty
var fallbackPipe io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
// The pty (and diff renderer) wrap to this width; apply it here, on the
// task's goroutine once the previous task has stopped, so it doesn't
// race that task's writes (see View.SetContentWidth).
view.SetContentWidth(width)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)
// Fall back to running the command without a pty: the diff renderer is
// lost, but the command's output still renders.
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
fallbackPipe = pipe
return execCmd, pipe
}
p = sp.Pty
gui.Mutexes.PtyMutex.Lock()
gui.viewPtmxMap[view.Name()] = p
gui.Mutexes.PtyMutex.Unlock()
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
}
onClose := func() {
gui.Mutexes.PtyMutex.Lock()
if p != nil {
p.Close()
}
if fallbackPipe != nil {
fallbackPipe.Close()
fallbackPipe = nil
}
delete(gui.viewPtmxMap, view.Name())
gui.Mutexes.PtyMutex.Unlock()
}
linesToRead := gui.linesToReadFromCmdTask(view)
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
})
return nil
}
// withPtyGitConfig returns args with extra git configuration for commands
// that render into a pty. On Windows, such a command is terminated at an
// arbitrary point of its execution when its task stops: tearing down the
// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default
// handler, which just calls ExitProcess. git's automatic index refresh
// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a
// diff against the worktree to write back refreshed stat information —
// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing
// in that window leaves a stale index.lock behind that the next git command
// chokes on. So don't let pty-rendered commands refresh the index;
// lazygit's foreground `git status` refreshes, which never run in a pty,
// keep the stat cache fresh instead.
// ptyRender runs the command in a pseudo-terminal. git invokes the stdin filter
// named by GIT_PAGER only when it talks to a terminal, and a renderer reads the
// width it lays out to off it.
//
// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove
// its lock files, so the refresh can stay enabled there and keep healing
// stale stat info.
func withPtyGitConfig(args []string, goos string) []string {
if goos != "windows" {
return args
}
// Most pty commands are direct git invocations, but the user-configured
// ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git
// in `sh -c`), and injecting git flags into those would corrupt them.
// Only direct git invocations get the config; that loses nothing, since
// the wrapped commands are log commands, which never take the index
// lock. (For direct invocations other than worktree diffs the config is
// simply a no-op.)
base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe")
if base != "git" {
return args
}
result := make([]string, 0, len(args)+2)
result = append(result, args[0])
result = append(result, "-c", "diff.autoRefreshIndex=false")
return append(result, args[1:]...)
}
// Must be called on the UI thread: it reads the view's dimensions, which the
// layout writes.
func (gui *Gui) ptyRender(spec renderSpec) (startRender, onCloseRender) {
view := spec.view
cmd := spec.cmd
func removeExistingTermEnvVars(env []string) []string {
return lo.Filter(env, func(envVar string, _ int) bool {
return !isTermEnvVar(envVar)
})
}
// git runs the stdin filter itself, as the pager it is told about here.
// Named even when there is none, so that git doesn't reach for the user's
// core.pager instead.
cmd.Env = append(cmd.Env, "GIT_PAGER="+spec.stdinFilter)
// Terminals set a variety of different environment variables
// to identify themselves to processes. This list should catch the most common among them.
func isTermEnvVar(envVar string) bool {
return strings.HasPrefix(envVar, "TERM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
cols, rows := gui.desiredPtySize(view)
var p oscommands.Pty
var fallbackPipe io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
// The pty (and diff renderer) wrap to this width; apply it here, on the
// task's goroutine once the previous task has stopped, so it doesn't
// race that task's writes (see View.SetContentWidth).
view.SetContentWidth(spec.width)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)
// Fall back to running the command without a pty: the diff renderer is
// lost, but the command's output still renders.
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
fallbackPipe = pipe
return execCmd, pipe
}
p = sp.Pty
gui.Mutexes.PtyMutex.Lock()
gui.viewPtmxMap[view.Name()] = p
gui.Mutexes.PtyMutex.Unlock()
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
}
onClose := func() {
gui.Mutexes.PtyMutex.Lock()
if p != nil {
p.Close()
}
if fallbackPipe != nil {
fallbackPipe.Close()
fallbackPipe = nil
}
delete(gui.viewPtmxMap, view.Name())
gui.Mutexes.PtyMutex.Unlock()
}
return start, onClose
}
-29
View File
@@ -1,29 +0,0 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithPtyGitConfig(t *testing.T) {
args := []string{"git", "-C", "/repo", "diff", "--color=always"}
assert.Equal(t,
[]string{"git", "-c", "diff.autoRefreshIndex=false", "-C", "/repo", "diff", "--color=always"},
withPtyGitConfig(args, "windows"))
assert.Equal(t, args, withPtyGitConfig(args, "linux"))
assert.Equal(t, args, withPtyGitConfig(args, "darwin"))
// A user-configured command that wraps git in a shell must not have git
// flags injected into it.
shellArgs := []string{"sh", "-c", "git log --graph {{branchName}} -- | sed -e s/x/y/"}
assert.Equal(t, shellArgs, withPtyGitConfig(shellArgs, "windows"))
// The guard recognizes git regardless of case and extension.
exeArgs := []string{"GIT.EXE", "diff"}
assert.Equal(t,
[]string{"GIT.EXE", "-c", "diff.autoRefreshIndex=false", "diff"},
withPtyGitConfig(exeArgs, "windows"))
}
+1
View File
@@ -34,6 +34,7 @@ var (
AttrUnderline = New().SetUnderline()
AttrBold = New().SetBold()
AttrDim = New().SetDim()
ColorMap = map[string]struct {
Foreground TextStyle
+14 -1
View File
@@ -4,6 +4,7 @@ import "github.com/gookit/color"
type Decoration struct {
bold bool
dim bool
underline bool
reverse bool
strikethrough bool
@@ -13,6 +14,10 @@ func (d *Decoration) SetBold() {
d.bold = true
}
func (d *Decoration) SetDim() {
d.dim = true
}
func (d *Decoration) SetUnderline() {
d.underline = true
}
@@ -26,12 +31,16 @@ func (d *Decoration) SetStrikethrough() {
}
func (d Decoration) ToOpts() color.Opts {
opts := make([]color.Color, 0, 3)
opts := make([]color.Color, 0, 5)
if d.bold {
opts = append(opts, color.OpBold)
}
if d.dim {
opts = append(opts, color.OpFuzzy)
}
if d.underline {
opts = append(opts, color.OpUnderscore)
}
@@ -52,6 +61,10 @@ func (d Decoration) Merge(other Decoration) Decoration {
d.bold = true
}
if other.dim {
d.dim = true
}
if other.underline {
d.underline = true
}
+19
View File
@@ -68,6 +68,25 @@ func TestMerge(t *testing.T) {
},
"\x1b[1mfoo\x1b[0m",
},
{
"dim attribute",
[]TextStyle{AttrDim},
TextStyle{
decoration: Decoration{dim: true},
Style: color.Style{color.OpFuzzy},
},
"\x1b[2mfoo\x1b[0m",
},
{
"dim attribute and color",
[]TextStyle{FgRed.SetDim()},
TextStyle{
fg: &Color{basic: &fgRed},
decoration: Decoration{dim: true},
Style: color.Style{fgRed, color.OpFuzzy},
},
"\x1b[31;2mfoo\x1b[0m",
},
{
"multiple attributes",
[]TextStyle{AttrBold, AttrUnderline},
+6
View File
@@ -63,6 +63,12 @@ func (b TextStyle) SetBold() TextStyle {
return b
}
func (b TextStyle) SetDim() TextStyle {
b.decoration.SetDim()
b.Style = b.deriveStyle()
return b
}
func (b TextStyle) SetUnderline() TextStyle {
b.decoration.SetUnderline()
b.Style = b.deriveStyle()
+15 -15
View File
@@ -17,11 +17,10 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
cmdStr,
).Debug("RunCommand")
manager := gui.getManager(view)
// Mark the view as loading synchronously (before the task's goroutine runs
// and before the next layout pass) so the layout doesn't clamp the scroll
// position to the not-yet-loaded content.
manager.StartLoading()
gui.getManager(view).StartLoading()
// Hold the scrollbar at the height the view has now (the previous render),
// while it still shows that render: once the re-render swaps in its first
// partial paint the displayed buffer is briefly short, and we don't want the
@@ -29,17 +28,23 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
view.FreezeScrollbarHeight()
// Snapshot the view width here, on the UI thread, so the task goroutine
// doesn't read the view's live dimensions while it streams output. It's
// applied inside start() below rather than now, because start() runs once
// the previous task has stopped -- applying it here would race that task's
// still-running writes (see View.SetContentWidth).
contentWidth := view.InnerWidth()
// doesn't read the view's live dimensions while it streams output.
spec := renderSpec{view: view, cmd: cmd, width: view.InnerWidth()}
return gui.newTaskForRender(spec, prefix, cmdStr, gui.plainRender)
}
// plainRender runs the command as it is, with its output going straight into
// a pipe.
func (gui *Gui) plainRender(spec renderSpec) (startRender, onCloseRender) {
var r io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
view.SetContentWidth(contentWidth)
// The view wraps to this width; apply it here, on the task's goroutine
// once the previous task has stopped, so it doesn't race that task's
// still-running writes (see View.SetContentWidth).
spec.view.SetContentWidth(spec.width)
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
execCmd, pipe := startCmdWithPipe(spec.cmd, gui.c.Log)
r = pipe
return execCmd, pipe
}
@@ -51,12 +56,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
}
}
linesToRead := gui.linesToReadFromCmdTask(view)
if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil {
gui.c.Log.Error(err)
}
return nil
return start, onClose
}
// startCmdWithPipe starts cmd with its stdout and stderr going to a single
+6 -6
View File
@@ -84,17 +84,17 @@ func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask {
return &RunCommandTask{Cmd: cmd, Prefix: prefix}
}
type RunPtyTask struct {
type RunDiffRendererTask struct {
Cmd *exec.Cmd
Prefix string
}
func (t *RunPtyTask) IsUpdateTask() {}
func (t *RunDiffRendererTask) IsUpdateTask() {}
func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask {
return &RunPtyTask{Cmd: cmd}
func NewRunDiffRendererTask(cmd *exec.Cmd) *RunDiffRendererTask {
return &RunDiffRendererTask{Cmd: cmd}
}
func NewRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunPtyTask {
return &RunPtyTask{Cmd: cmd, Prefix: prefix}
func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRendererTask {
return &RunDiffRendererTask{Cmd: cmd, Prefix: prefix}
}
+1
View File
@@ -204,6 +204,7 @@ func (gui *Gui) configureViewProperties() {
(*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor
(*mapping.viewPtr).SelFgColor = gui.g.SelFgColor
(*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor
(*mapping.viewPtr).SelTextColor = theme.GocuiSelectedLineFgColor
}
gui.Views.MenuFilterFrame.FrameRunes = frameRunesWithTopCorners(frameRunes, teeLeft, teeRight)
+43 -3
View File
@@ -239,6 +239,14 @@ type TranslationSet struct {
ForcePush string
ForcePushPrompt string
ForcePushDisabled string
ForcePushBranchesPrompt string
ForcePushBranchesDisabled string
BranchesBelowHaveCommitsToPush string
PushBranchAndBranchesBelow string
PushOnlyCurrentBranch string
BranchesBelowHaveChangedOnRemote string
PullBranchAndBranchesBelow string
PullOnlyCurrentBranch string
UpdatesRejected string
UpdatesRejectedAndForcePushDisabled string
CheckForUpdate string
@@ -369,6 +377,9 @@ type TranslationSet struct {
FwdNoUpstream string
FwdNoLocalUpstream string
FwdCommitsToPush string
FwdLocalOnlyCommits string
FwdUncommittedChanges string
FwdBranchRebasingOrBisecting string
PullRequestNoUpstream string
PullRequestChecksPassing string
PullRequestChecksPending string
@@ -598,14 +609,23 @@ type TranslationSet struct {
LightweightTag string
AnnotatedTag string
DeleteTagTitle string
DeleteTagsTitle string
DeleteLocalTag string
DeleteLocalTags string
DeleteRemoteTag string
DeleteRemoteTags string
DeleteLocalAndRemoteTag string
DeleteLocalAndRemoteTags string
SelectRemoteTagUpstream string
SelectRemoteTagsUpstream string
DeleteRemoteTagPrompt string
DeleteRemoteTagsPrompt string
DeleteLocalAndRemoteTagPrompt string
DeleteLocalAndRemoteTagsPrompt string
RemoteTagDeletedMessage string
RemoteTagsDeletedMessage string
PushTagTitle string
PushTagsTitle string
PushTag string
PushTagTooltip string
NewTag string
@@ -1222,9 +1242,9 @@ func EnglishTranslationSet() *TranslationSet {
Refresh: "Refresh",
RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.",
Push: "Push",
PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.",
PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too.",
Pull: "Pull",
PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.",
PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too.",
MergeConflictsTitle: "Merge conflicts",
MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.",
MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').",
@@ -1400,6 +1420,14 @@ func EnglishTranslationSet() *TranslationSet {
ForcePush: "Force push",
ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing",
ForcePushBranchesPrompt: "The following branches have diverged from their remote branches:\n\n{{.branches}}\n\nPress {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
ForcePushBranchesDisabled: "Some of these branches have diverged from their remote branches and you've disabled force pushing",
BranchesBelowHaveCommitsToPush: "The following branches stacked below '{{.branchName}}' also have commits to push:",
PushBranchAndBranchesBelow: "Push all these branches in addition to the current one",
PushOnlyCurrentBranch: "Push only '{{.branchName}}'",
BranchesBelowHaveChangedOnRemote: "The following branches stacked below '{{.branchName}}' have also changed on the remote:",
PullBranchAndBranchesBelow: "Pull all these branches in addition to the current one",
PullOnlyCurrentBranch: "Pull only '{{.branchName}}'",
UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.",
UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing",
CheckForUpdate: "Check for update",
@@ -1478,7 +1506,7 @@ func EnglishTranslationSet() *TranslationSet {
ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).",
ReturnToFilesPanel: `Return to files panel`,
FastForward: `Fast-forward`,
FastForwardTooltip: "Fast-forward selected branch from its upstream.",
FastForwardTooltip: "Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates).",
FastForwarding: "Fast-forwarding",
FoundConflictsTitle: "Conflicts!",
ViewConflictsMenuItem: "View conflicts",
@@ -1537,6 +1565,9 @@ func EnglishTranslationSet() *TranslationSet {
FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
FwdLocalOnlyCommits: "Cannot fast-forward '{{.branchName}}' because it has commits which were never on its remote branch",
FwdUncommittedChanges: "Cannot fast-forward '{{.branchName}}' because the worktree it is checked out in has uncommitted changes",
FwdBranchRebasingOrBisecting: "Cannot fast-forward '{{.branchName}}' because it is being rebased or bisected in worktree {{.worktreeName}}",
PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream",
PullRequestChecksPassing: "Passing",
PullRequestChecksPending: "Pending",
@@ -1767,14 +1798,23 @@ func EnglishTranslationSet() *TranslationSet {
AnnotatedTag: "Annotated tag",
LightweightTag: "Lightweight tag",
DeleteTagTitle: "Delete tag '{{.tagName}}'?",
DeleteTagsTitle: "Delete selected tags?",
DeleteLocalTag: "Delete local tag",
DeleteLocalTags: "Delete local tags",
DeleteRemoteTag: "Delete remote tag",
DeleteRemoteTags: "Delete remote tags",
DeleteLocalAndRemoteTag: "Delete local and remote tag",
DeleteLocalAndRemoteTags: "Delete local and remote tags",
RemoteTagDeletedMessage: "Remote tag deleted",
RemoteTagsDeletedMessage: "Remote tags deleted",
SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':",
SelectRemoteTagsUpstream: "Remote from which to remove the selected tags:",
DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?",
DeleteRemoteTagsPrompt: "Are you sure you want to delete the selected tags from '{{.upstream}}'?",
DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?",
DeleteLocalAndRemoteTagsPrompt: "Are you sure you want to delete the selected tags from both your machine and from '{{.upstream}}'?",
PushTagTitle: "Remote to push tag '{{.tagName}}' to:",
PushTagsTitle: "Remote to push the selected tags to:",
// Using 'push tag' rather than just 'push' to disambiguate from a global push
PushTag: "Push tag",
PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.",
+14
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/samber/lo"
)
type Git struct {
@@ -27,6 +28,19 @@ func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git {
})
}
// Asserts the names of all tags on the remote, in alphabetical order
func (self *Git) RemoteTagNames(remote string, expectedNames []string) *Git {
return self.expect([]string{"git", "ls-remote", "--tags", "--refs", remote}, func(output string) (bool, string) {
names := lo.FilterMap(strings.Split(output, "\n"), func(line string, _ int) (string, bool) {
_, ref, found := strings.Cut(line, "\t")
return strings.TrimPrefix(ref, "refs/tags/"), found
})
actual := strings.Join(names, "\n")
expected := strings.Join(expectedNames, "\n")
return actual == expected, fmt.Sprintf("Expected the tags on %s to be\n%s\nbut got\n%s", remote, expected, actual)
})
}
func (self *Git) assert(cmdArgs []string, expected string) *Git {
self.expect(cmdArgs, func(output string) (bool, string) {
return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output)
+1 -1
View File
@@ -11,7 +11,7 @@ var Highlight = NewIntegrationTest(NewIntegrationTestArgs{
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.Log.ShowGraph = "always"
config.GetUserConfig().Gui.AuthorColors = map[string]string{
config.GetUserConfig().Gui.Theme.AuthorColors = map[string]string{
"CI": "red",
}
},
+1 -1
View File
@@ -4,7 +4,7 @@ import "github.com/jesseduffield/lazygit/pkg/config"
// Gives us nicer colours when we generate a git repo history with `shell.CreateRepoHistory()`
func setGeneratedAuthorColours(config *config.AppConfig) {
config.GetUserConfig().Gui.AuthorColors = map[string]string{
config.GetUserConfig().Gui.Theme.AuthorColors = map[string]string{
"Fredrica Greenhill": "#fb5aa3",
"Oscar Reuenthal": "#86c82f",
"Paul Oberstein": "#ffd500",
@@ -0,0 +1,49 @@
package diff
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RenderThroughAPipe = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A stdin filter renders the diff when it is fed through a pipe rather than run in a pty",
ExtraCmdArgs: []string{},
Skip: false,
// This is how a render works on Windows, where a pty can't carry a
// renderer's output faithfully. Ask for it here so that the path is
// covered on the platforms the integration tests do run on.
ExtraEnvVars: map[string]string{"LAZYGIT_RENDER_WITHOUT_PTY": "1"},
SetupConfig: func(cfg *config.AppConfig) {
// Reports the width it was given, then passes the diff through. git
// only runs a filter of its own when it talks to a terminal, so the
// filter running at all says the pipeline was built here.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `echo "rendered at {{width}} columns"; cat`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("one").IsSelected(),
)
t.Views().Main().
// The width reaches the filter on its command line, since with no
// terminal it has nowhere to read it from.
Content(MatchesRegexp(`rendered at \d+ columns`)).
ContainsLines(
Equals("diff --git a/file1 b/file1"),
Contains("new file mode"),
Contains("index "),
Equals("--- /dev/null"),
Equals("+++ b/file1"),
Equals("@@ -0,0 +1 @@"),
Equals("+one"),
)
},
})
@@ -0,0 +1,42 @@
package diff
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StatUsesTheViewWidth = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The diffstat of a commit is laid out to the width of the view showing it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// git's own diff, so that the render runs as a plain command rather
// than through a renderer.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Type: "rawGit"},
}
},
SetupRepo: func(shell *Shell) {
// Enough added lines that git scales the graph to the width it has,
// rather than drawing one mark per line.
lines := make([]string, 200)
for i := range lines {
lines[i] = "line " + strconv.Itoa(i)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("add file1")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("add file1").IsSelected(),
)
t.Views().Main().
Content(MatchesRegexp(`(?m)^ file1 \| 200 \+{80,}$`))
},
})
@@ -0,0 +1,49 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardBehindBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward a branch that is behind its upstream branch, without checking it out",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.NewBranch("feature")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("feature", "origin/feature")
// remove the two commits so that there is something to fast-forward to
shell.HardReset("HEAD~2")
shell.Checkout("master")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("feature ↓2"),
).
SelectNextItem().
Press(keys.Branches.FastForward).
Lines(
Contains("master"),
Contains("feature ✓").IsSelected(),
).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("three"),
Contains("two"),
Contains("one"),
)
},
})
@@ -0,0 +1,57 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardBehindBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward the checked out branch when it is behind its upstream branch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "content")
shell.Commit("one")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
// remove the two commits so that there is something to fast-forward to
shell.HardReset("HEAD~2")
// a change that the fast-forward doesn't get in the way of
shell.UpdateFile("file", "changed")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("one"),
)
t.Views().Branches().
Focus().
Lines(
Contains("master ↓2").IsSelected(),
).
Press(keys.Branches.FastForward).
Lines(
Contains("master ✓").IsSelected(),
)
t.Views().Commits().
Lines(
Contains("three"),
Contains("two"),
Contains("one"),
)
// Moving the branch forward doesn't touch the change
t.Views().Files().
Lines(
Contains("file"),
)
},
})
@@ -0,0 +1,44 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardBehindBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward a branch that is behind its upstream branch in a repo that keeps no reflogs",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// This is the default in a bare repository, so it is what people with a
// bare repo and linked worktrees work with
shell.SetConfig("core.logAllRefUpdates", "false")
shell.EmptyCommit("one")
shell.NewBranch("feature")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("feature", "origin/feature")
// remove the two commits so that there is something to fast-forward to
shell.HardReset("HEAD~2")
shell.Checkout("master")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("feature ↓2"),
).
SelectNextItem().
Press(keys.Branches.FastForward).
Lines(
Contains("master"),
Contains("feature ✓").IsSelected(),
)
},
})
@@ -0,0 +1,62 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Try to fast-forward a branch that is being rebased in another worktree",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "content")
shell.Commit("one")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
// remove a commit so that there is something to fast-forward to
shell.HardReset("HEAD~1")
shell.NewBranch("other")
shell.AddWorktreeCheckout("master", "../linked-worktree")
// the failing exec stops the rebase after picking "two", with HEAD
// detached from master
shell.RunCommandExpectError([]string{"git", "-C", "../linked-worktree", "rebase", "--exec", "false", "HEAD~1"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("other").IsSelected(),
Contains("master (worktree linked-worktree) ↓1"),
).
NavigateToLine(Contains("master")).
Press(keys.Branches.FastForward)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")).
Confirm()
t.Views().Worktrees().
Focus().
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Select)
t.Views().Information().Content(Contains("Rebasing"))
t.Views().Commits().
Lines(
Contains("─── Pending rebase todos"),
Contains("─── Commits"),
Contains("two"),
Contains("one"),
)
},
})
@@ -0,0 +1,67 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardBranchesInWorktreesWithOneFailing = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward two branches checked out in other worktrees when the fast-forward fails in the first one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "content")
shell.Commit("one")
shell.NewBranch("branch-a")
shell.CreateFileAndAdd("a-file", "a")
shell.Commit("a")
shell.Checkout("master")
shell.NewBranch("branch-b")
shell.CreateFileAndAdd("b-file", "b")
shell.Commit("b")
shell.Checkout("master")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("branch-a", "origin/branch-a")
shell.SetBranchUpstream("branch-b", "origin/branch-b")
// remove a commit from each branch so that there is something to
// fast-forward to
shell.RunCommand([]string{"git", "branch", "-f", "branch-a", "branch-a~1"})
shell.RunCommand([]string{"git", "branch", "-f", "branch-b", "branch-b~1"})
shell.AddWorktreeCheckout("branch-a", "../worktree-a")
shell.AddWorktreeCheckout("branch-b", "../worktree-b")
// an untracked file that the fast-forward in worktree-a would overwrite
shell.CreateFile("../worktree-a/a-file", "mine")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("branch-a (worktree worktree-a) ↓1"),
Contains("branch-b (worktree worktree-b) ↓1"),
).
SelectNextItem().
Press(keys.Universal.ToggleRangeSelect).
SelectNextItem().
Press(keys.Branches.FastForward)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("would be overwritten by merge")).
Confirm()
t.Views().Branches().
Lines(
Contains("master"),
Contains("branch-a (worktree worktree-a) ↓1").IsSelected(),
Contains("branch-b (worktree worktree-b) ✓").IsSelected(),
)
},
})
@@ -0,0 +1,39 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward a branch that has diverged from its upstream because the upstream branch was rewritten",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
createBranchRewrittenOnTheRemote(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("feature ↓2↑2"),
).
SelectNextItem().
Press(keys.Branches.FastForward).
Lines(
Contains("master"),
Contains("feature ✓").IsSelected(),
).
PressEnter()
t.Views().SubCommits().
IsFocused().
Lines(
Contains("three-rewritten"),
Contains("two-rewritten"),
Contains("one"),
)
},
})
@@ -0,0 +1,45 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward the checked out branch after its upstream branch was rewritten",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
createBranchRewrittenOnTheRemote(shell)
shell.Checkout("feature")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("three"),
Contains("two"),
Contains("one"),
)
t.Views().Branches().
Focus().
Lines(
Contains("feature ↓2↑2").IsSelected(),
Contains("master"),
).
Press(keys.Branches.FastForward).
Lines(
Contains("feature ✓").IsSelected(),
Contains("master"),
)
t.Views().Commits().
Lines(
Contains("three-rewritten"),
Contains("two-rewritten"),
Contains("one"),
)
},
})
@@ -0,0 +1,40 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranchWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Try to fast-forward a branch that has a commit of its own on top of a rewritten upstream branch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
createBranchRewrittenOnTheRemote(shell)
shell.Checkout("feature")
shell.EmptyCommit("mine")
shell.Checkout("master")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("feature ↓2↑3"),
).
SelectNextItem().
Press(keys.Branches.FastForward)
t.ExpectPopup().Alert().Title(Equals("Error")).
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
Confirm()
t.Views().Branches().
Lines(
Contains("master"),
Contains("feature ↓2↑3").IsSelected(),
)
},
})
@@ -0,0 +1,45 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranchWithStaleSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward a branch with a rewritten upstream branch while its worktree has a submodule checked out at a different commit than the one recorded",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial")
shell.CloneIntoSubmodule("sub", "sub")
createBranchRewrittenOnTheRemote(shell)
shell.Checkout("feature")
shell.RunCommand([]string{"git", "-C", "sub", "commit", "--allow-empty", "-m", "newer"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Lines(
Contains("sub"),
)
t.Views().Branches().
Focus().
Lines(
Contains("feature ↓2↑2").IsSelected(),
Contains("master"),
).
Press(keys.Branches.FastForward).
Lines(
Contains("feature ✓").IsSelected(),
Contains("master"),
)
// The submodule was left alone
t.Views().Files().
Lines(
Contains("sub"),
)
},
})
@@ -0,0 +1,44 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranchWithUncommittedChanges = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Try to fast-forward a branch with a rewritten upstream branch while its worktree has uncommitted changes",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "content")
createBranchRewrittenOnTheRemote(shell)
shell.Checkout("feature")
shell.UpdateFile("file", "changed")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("feature ↓2↑2").IsSelected(),
Contains("master"),
).
Press(keys.Branches.FastForward)
t.ExpectPopup().Alert().Title(Equals("Error")).
Content(Contains("Cannot fast-forward 'feature' because the worktree")).
Confirm()
t.Views().Branches().
Lines(
Contains("feature ↓2↑2").IsSelected(),
Contains("master"),
)
t.Views().Files().
Lines(
Contains("file"),
)
},
})
@@ -0,0 +1,41 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Try to fast-forward a branch with a rewritten upstream branch in a repo that keeps no reflogs",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// Without reflogs there is no record of the values the remote-tracking
// branch had before, so lazygit can't tell whether the commits that
// the branch is ahead by were ever on the remote branch
shell.SetConfig("core.logAllRefUpdates", "false")
createBranchRewrittenOnTheRemote(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("feature ↓2↑2"),
).
SelectNextItem().
Press(keys.Branches.FastForward)
t.ExpectPopup().Alert().Title(Equals("Error")).
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
Confirm()
t.Views().Branches().
Lines(
Contains("master"),
Contains("feature ↓2↑2").IsSelected(),
)
},
})
@@ -0,0 +1,45 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FastForwardRewrittenStackOfBranches = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fast-forward a whole stack of branches whose upstream branches were rewritten",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
},
SetupRepo: func(shell *Shell) {
createStackRewrittenOnTheRemote(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("branch1 ↓1↑1"),
Contains("branch2 ↓2↑2"),
Contains("branch3 ↓3↑3"),
).
SelectNextItem().
Press(keys.Universal.ToggleRangeSelect).
SelectNextItem().
SelectNextItem().
Lines(
Contains("master"),
Contains("branch1 ↓1↑1").IsSelected(),
Contains("branch2 ↓2↑2").IsSelected(),
Contains("branch3 ↓3↑3").IsSelected(),
).
Press(keys.Branches.FastForward).
Lines(
Contains("master"),
Contains("branch1 ✓").IsSelected(),
Contains("branch2 ✓").IsSelected(),
Contains("branch3 ✓").IsSelected(),
)
},
})

Some files were not shown because too many files have changed in this diff Show More