Compare commits

..
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 6ace682e50 Follow the selection with the search in the focused main view
Search the diff, then move the selection with the arrow keys, and
pressing "n" goes to the match after the one the search was last on
rather than to the one after the selection. So walking through the diff
and then picking up the search where you got to takes as many presses as
there are matches behind you.

List views and the staging view keep the current match in step with their
selection; do the same here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8c51a920b2 Draw the diff selection as a narrow bar rather than across the line
Now that a diff view always carries a selection, the highlight fights the
diff itself for the line's colours. Painting the selection across the whole
line takes over the background, and a selected hunk becomes one solid block
with no boundary between what was removed and what replaced it — the more
lines you select, the less you can read.

This isn't specific to renderers like delta that say which side of the diff
a line is on by colouring its background, though they suffer most: git's own
output puts red and green text on that background, which reads badly too.
Since there is no rendering of a diff that a full-width highlight doesn't
degrade, there is nothing here worth configuring — the bar is simply what
diff views use.

Left edge only, and two columns wide: every convention for marking a row of
coloured content — change bars, diff gutters, selection gutters — puts the
marker on the left, and bracketing both edges reads as framing instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfde175e8a Edit the selected line of a diff from the focused main view
Reading a diff is often how you notice something to fix, and the file and
line are right there in front of you — so pressing edit opens the file at
the line under the selection, as it does in the staging view.

The line number the diff shows is the line number in the version the diff
is of, which for a commit's diff is not where that line sits today, so it
is carried forward the same way clicking a diff-renderer hyperlink already
does. A file header names no line, so it just opens the file.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfb88005c8 Jump by hunk and by file in the focused main view
Reading a diff of any size means moving in bigger steps than a line at a
time, which the staging view offers and the main view didn't. So the same
hunk keys work here, and n / N step from file to file — worth having only
here, since a diff spanning several files is something the staging view
never showed.

Where "the next file" begins isn't in the text: a diff renderer may print
whatever it likes above a file's content. So navigation lands on the first
row that states which file it belongs to, which is the file's header when
the source says so and its first content line otherwise. The anchor's own
file is found by scanning down rather than to the nearest row either way —
having just landed on a header, the nearest row above belongs to the file
we came from, and taking it would send the next press back where we
started.

A file you go to is brought to the top of the view, since the file is what
you went there for and the more of it is on screen the better. That only
applies where the view has to scroll at all: a file already on screen
leaves it where it is. In hunk mode what ends up selected is the file's
first change rather than the row the file begins at, and a large context
size can put that change further down than a screenful; the selection is
scrolled into view afterwards as any other jump's is, and the alignment
gives way where the two can't both hold.

The diff loads lazily, so a target below the loaded portion isn't there to
be found; rather than doing nothing, we read the rest in and look again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 a5e72581f9 Select a range of diff lines by dragging
Selecting a range with the mouse is the obvious gesture once clicking
selects a line, and it's the only way to get a range without knowing the
keybindings.

The range has to be anchored where the mouse went down, which the view
can't tell us: a click in hunk mode selects a whole block, leaving the
view's own range anchor at the block's far end, so a drag from there would
grow the selection from the wrong end. So the clicked line is remembered
when the click happens, and the drag anchors there.

A drag that reaches the edge of the view keeps scrolling, using the same
autoscroller the staging view does — mouse capture means the pointer can be
dragged past the edge, and there is more diff down there than fits on
screen. Unlike the staging view, whose content is a string that is always
there in full, this diff loads lazily, so scrolling down has to keep
reading it in or the autoscroll would stop at the loaded edge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 11a899852c Select a whole change block when focusing the main view in hunk mode
Users who set hunk mode as their default get it when entering the staging
view, and the focused main view is on its way to replacing that view, so it
has to behave the same: focusing selects a whole change block, and clicking
a change line selects that line's block, ready to act on. A click on a
context line still selects only that line, since the click points at it
precisely — you may well want to edit it. A click inside the block that is
already selected does the same, giving hunk mode up and going back to line
by line. That keeps a single line reachable with the mouse in a block too
long to see the end of, and matches how a click inside a range selection
already collapses it.

The block offered up is the first one that begins on screen, so that its
whole extent can be seen before acting on it. Only when none does — a change
too long to fit on the screen — is the block that reaches into the view from
above taken instead, and the selection then extends to its first line off
screen: focusing a diff must not move it, so nothing here scrolls.

The staging view makes one exception, and so must we, or a file that is one
solid block of changes (a file you just created, or deleted) would come up
with all of it selected. The staging view asks the parsed patch; we ask the
rendered diff the same question, which needs no second git invocation and
works over a whole commit's diff, where the answer differs per file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 83599e143b Make selecting a change block reachable without a controller
Focusing the main view is about to establish a hunk selection, and that
happens in a free function shared with the controller that focuses the
main view from a side panel, which has no main view controller to hand.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 ad95b633eb Let a main pane be named without the context package
The mode of a diff view's selection lives on the context of the pane showing
it, and until now only the controllers that drive the selection needed to
name that pane, all of which have it as the concrete context it is.

A side panel is about to be handed the pane a command was invoked in, so
that it can act on what is selected there — and it can't be handed a
context, the interface it is handed one through being declared where a
context's concrete type isn't available. So the state moves to where such an
interface can speak of it, and the panes gain one: a context that has a diff
selection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 6a6683729f Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 81c27b2ff8 Separate reading a fixed number of lines from reading to the end
ReadToEnd holds a gocui task while it reads, so that lazygit doesn't count as
idle while something waits on the result. That has nothing to do with reading
all the way to the end, and the next commit needs it for a bounded read too, so
the two are pulled apart.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 a0b9ca978c Guard View.LinesHeight against a concurrent write
The count comes from the view's buffer, which a rendering task appends to on
its own goroutine, so reading it without the write mutex is a data race. Nobody
called it until now, which is why nothing has tripped over it; the next commit
does, from the UI thread while a render is still loading.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ad3a337675 Take the focused main view's context by its concrete type
Both callers pass a main context, and focusing one is about to need more
of it than the Context interface offers. Saying so in the signature also
retires the type assertion that was there only to reach ClearSearchString.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 50d9900b72 Remove the plumbing for clicking the focused main view
With both implementations gone, nothing is left that lets a side panel
handle a click in the focused main view, so the mechanism for attaching
one to a context can go as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ba3643149a Stop diving into a patch explorer when clicking the focused main view
Clicking a line of the focused main view entered the staging or patch
building panel at that line. The focused main view is about to gain a
selection of its own, which is what a click there should set — and with
the explorers on their way out, the dive has nowhere to go.

Nothing replaces the gesture yet, so for the next couple of commits a
click in the focused main view does nothing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 75d153edf5 Classify which side panels show a diff in their main view
The focused main view is about to show a selection, but only where there
are diff lines to act on: a branch's commit log or the status dashboard
has nothing to select. Rather than have the main view guess from the
rendered content, let the side panels say so, since each of them knows
what it renders.

The classification is finer than a yes/no because acting on a selection
means different things per panel — staging into the working tree for the
files panel, taking lines into a custom patch for the commit panels — and
those actions want the same one answer as this. Only whether a panel
shows a diff at all is read for now.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 46bd2ddb23 Fold ViewSelectionController into MainViewController
The focused main view is about to get a real diff selection, which makes
its up/down/page/top/bottom keys mode-aware: what they do depends on the
select state the main view controller owns. Keeping them in a separate
controller would mean either duplicating that state or reaching across
controllers for it, so move them to where the state will live. The two
controllers were attached to the same pair of contexts and nothing else,
so nothing else can notice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ea6d77c3e6 Ask diff renderers for OSC 1717 metadata
A renderer that speaks the protocol emits nothing unless it is asked to,
so that its output stays plain wherever it is used outside lazygit. The
variable names the versions we understand.

git is one of the renderers we ask. It has no pager to spawn, and so no
terminal to spawn one in, but it still renders the diff itself — for the
word-diff formats, whose markup nothing else could resolve — so the
request has to be made before we decide a pty isn't needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 eeaf94c79e Take a diff renderer at its word about a diff line
Where a renderer states which line of which file it is rendering, take
that as the line's identity. The renderer knows, and for a rendering
that no longer looks like a diff nothing else does. Reading the rendered
text stays the way for the renderings that keep a diff's structure, and
for the renderers that say nothing at all.

Which of the two is used is settled for the rendering as a whole, not
row by row. A rendering with records is not parsed at all, not even for
the rows the renderer says nothing about. Its text is no unified diff,
and a row of it can read like one. Under delta, a commit that adds a
test whose input is a diff shows the test's "diff --git a/img.png
b/img.png" line as a row of its own; parsed, that row opens a file
section that runs to the end of the rendering, and every row delta puts
between hunks and files comes out as a header of a file called img.png.
So the untagged rows of such a rendering stay unresolved, as the
protocol has it (spec section 6.4).

Header records are accepted too. This lets a renderer point at a file
with no content lines, such as a pure rename, a mode change or a binary
file: for those files the header is the only row in the diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 828c73bad4 Rename parsedDiffLine.RelPath to Path
A diff line's identity is about to become recoverable from a second
source, a diff renderer's own records, and a renderer states the path
however it likes — absolute paths included. The field can't promise
repo-relative any more.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 84ddc5bacf Keep the OSC 1717 records that cover no cell
Wherever two diff lines end up on one rendered line, a renderer emits
their records back to back: the deletion and the addition of a
modification collapsed into a single column, or a banner announcing a
file and its first hunk at once. A changed line that is empty is
rendered as its record alone. Attaching a record only to the cells it
precedes loses all of these — the last record of a run wins, and an
empty changed line becomes a line we can say nothing about at all.

Give such a record a cell of its own instead. It renders nothing, so the
diff looks the same, but the line keeps every record it was given.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 d3db58c2ec Swallow a diff renderer's protocol handshake
Before the diff, a conforming renderer emits one OSC 1717 record that
carries only the version — its way of announcing that it speaks the
protocol at all, without a host having to inspect what it renders. It
describes no line, so keeping it would give the first line of the diff a
record that says nothing about it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 d3acac38c4 Read the OSC 1717 records a diff renderer emits
A diff renderer that restructures the diff — into columns, or with the
+/- markers replaced by colour — leaves us no way to tell which line of
which file a rendered row came from, which is what acting on the row
requires. The OSC 1717 protocol has the renderer say so directly: it
prefixes each line it renders with a record naming the file and the
line's position in the old and new versions of it.

Attach each record to the cells it precedes, so that a row's records
survive wrapping and the columns of a side-by-side rendering, and hand
them to readers together with the row's text: the two have to describe
the same buffer, and a re-render can rebuild it between two reads.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 e0deb30faf Recognize OSC numbers with more than one digit
The OSC parser dispatched on a single character, so only the
single-digit OSC 8 could ever be recognized; the diff-line metadata
protocol we are about to read uses OSC 1717.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 36755f07f1 Decode the path of a diff header the way git writes it
git doesn't just print the path: it terminates it with a tab when the
path contains a space, and C-quotes the whole field when the path
contains anything it won't print raw — with core.quotePath, which is on
by default, that means any non-ASCII byte, so a file called café shows
up as "b/caf\303\251".

Taking the field verbatim therefore gives a path that doesn't exist, for
a whole class of perfectly ordinary file names. The quoting is Go's own
string syntax, octal escapes and all, so decoding it is a call to
strconv.Unquote.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 1cf479d651 Recover the identity of a diff line from the rendered diff
To act on the line the user is pointing at in a diff view — to stage it,
to open it in an editor, to keep the cursor on it while the diff is
regenerated — we need to know which file it belongs to and where it sits
in that file. Only the diff the view was rendered from knows that, and
by the time it's on screen all we have is text.

So parse it back: the view's contents are (usually) a unified diff, and
running them through the patch parser recovers each row's file, kind and
line numbers. "Usually" is why this goes behind a seam, and why it
answers "I don't know" rather than guessing: a diff renderer is free to
restructure what it prints, and a wrong answer here means acting on the
wrong line of the wrong file.

Parsing a whole buffer is a separate entry point from parsing a single
line, because a caller resolving every row of a large diff must not
re-parse a file's section once per line of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 e71d497157 Keep a view's lines as they were written, beside their cells
A reader that parses a view's content rather than showing it wants the
text the writer wrote, and the cells don't always spell it. A tab is
expanded into the spaces it fills, so a line read back from the cells
ends in one to four spaces where the writer put a tab. A carriage
return moves the write cursor back to the start of the line, so the
text written after it overwrites what came before.

The parser of the main view's diff, which the next commit adds, meets
the first case in every header of a file whose path contains a space.
git terminates the path field of a "---" or "+++" line with a tab
then, and a parser reading the cells takes the spaces the tab became
for part of the path. That path names a file that doesn't exist, so
the file's lines can't be acted on.

Keep the text as written per line, from the first character on that
the cells spell differently, so that a line without a tab or a
carriage return costs nothing. LinesAsWritten hands it out the way
BufferLines hands out the cells' text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 89e578123e Clear a pending newline when overwriting lines in place
OverwriteLines is asked for a line and writes the one below it when the
write before it ended in a newline. The view holds such a newline back
until more content arrives, so that it doesn't end in an empty line,
and OverwriteLines moved the write cursor without letting go of it, so
the write that followed advanced to the next line first.

Move the cursor through SetWritePos, which drops the pending newline
along with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 7c355a3801 Demonstrate that overwriting lines after a pending newline lands a line low
A view holds back the newline that ends a write until more content
arrives, so that it doesn't end in an empty line. OverwriteLines moves
the write cursor to the line it is given without letting go of that
pending newline, so the write that follows advances first and lands on
the line below. Nothing in lazygit overwrites lines right after such a
write today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 7455a42432 Let callers map between view lines and buffer lines
Everything about a diff's content — which file and line a row belongs
to, whether it's a change — is a property of the unwrapped buffer line,
while the cursor, clicks and the range selection all speak in view
lines, which count wrapped segments. Reading the content under the
cursor therefore needs the mapping in both directions, and doing it
outside gocui isn't possible: the wrapping is internal, and the caller
couldn't take the view's lock across the lookup and the read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 b61bd44a24 Add a well-formedness check for a parsed patch
Parse is lenient: it takes any text and reads a diff out of it, which is
what we want when we hand it a diff, but it has no way to say "this
isn't one". We're about to parse the *rendered* contents of a diff view,
which a diff renderer is free to restructure — putting the line numbers
in a gutter, say, shifts the +/- marker off the start of each body line,
so every line reads as context and the parse silently lies about which
lines are changes.

Comparing each hunk's body against the lengths its header declares
catches exactly that, without teaching us anything about any particular
renderer's layout: a faithful unified diff agrees with its headers, a
restructured one doesn't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 6049af5683 Add an old-file counterpart to Patch.LineNumberOfLine
Identifying a change line of a diff by its file line number needs both
sides: two consecutive deletions sit at the same new-file position, so
only their old-file line numbers tell them apart. LineNumberOfLine only
answers for the new file, which leaves deletions ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 eeb59aff16 Keep a resized view's place in the content it wraps
Wrapping the content at another width moves every line of it to a
different view line. The positions into the view are all view lines: the
scroll offset, the cursor, a range's anchor. Each of them is then left
pointing at a line it was never on. Committing the last of the staged
changes widens the main view by half a screen, and that moves a selected
hunk somewhere else entirely.

Carry the positions through the lines of content they were on. A position
always meant a line of content rather than a view line. The line the
cursor is on keeps the row it was drawn on, so it stays in front of the
user rather than the view scrolling under it; a view with no cursor on
screen keeps its own place instead. A range's ends go on the outermost
segments of their lines, since a range covers lines of content and not
the segments those lines are drawn as.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0758533125 Demonstrate that resizing a wrapping view moves its selection
The scroll offset, the cursor and a range's anchor are all view lines, which
count the segments each line of the content is wrapped into. A change of
width wraps the content differently, so every one of them ends up on a
different line than the one it was put on.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0bce007abe Report a wrapped selection by the lines of content it covers
A test asks which lines of a view are selected; a wrapping view's cursor and
range anchor answer in view lines, which count the segments each line is
drawn as. Going through the segment-to-line mapping keeps the answer in the
terms the question was asked in, and a line the selection covers several
segments of is reported once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0ae23b15a1 Demonstrate that a wrapped selection is reported by segment
The lines a selection covers are asked for by view line, which counts the
segments a wrapping view breaks a line into, and then used to index the
content, whose lines are unwrapped. The two agree only for a view whose
content doesn't wrap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5.5 0afb94e97b Lay git's own diff out to the width the layout gives the view
Changing the screen mode renders the main view again, and so does
anything else that changes its size along with what it shows. With git's
own diff, that render laid a commit's diffstat out to the width the view
had before. The graph then wrapped onto rows of its own in a view that
got narrower, and stopped short in one that got wider.

A diff renderer's render is created after the layout pass, since only
the layout settles the view's size, and takes its width there. git's own
diff took its width straight away instead, when the render was asked
for. Create its task after the layout as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:50:48 +02:00
Stefan HallerandClaude Opus 5.5 6b38e8fcde Demonstrate that git's diffstat keeps the width from before a screen mode change
Pressing + in the commits panel moves to half-screen mode and renders
the commit's diff again in the narrower main view. With git's own diff,
the diffstat graph comes out as wide as the view was before, and wraps
onto rows of its own.

ContainsViewLines asserts on the rows the view draws, after wrapping.
The existing line assertions see the lines of the content and can't tell
a wrapped line from one that fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:42:10 +02:00
Stefan Haller ff375b124d Revert the change of InactiveBorderColor to "dim" (#6080)
This was an attempt to make lazygit's overall UI look more "elegant",
but it has too many problems. The biggest is that we have panels that
combine two views (e.g. a prompt with suggestions below, or the commit
message editor with subject and body), and showing the frame of the
inactive half of these as "dim" looks wrong; we'd have to add a new
concept of a view that doesn't have the focus but otherwise still looks
active.

Also, some terminals don't do a good job of showing lines made of dim
box drawing characters (e.g. Zed's builtin terminal). This could
probably be improved by using a grey hex color instead of dim, but then
we'd have to pick one that looks good with all possible terminal color
scheme.

That's too many questions, and it's not important enough to spend time
on this, so just revert the change.
2026-09-30 08:44:09 +02:00
Stefan Haller f2a1e178ba Revert the change of InactiveBorderColor to "dim"
This was an attempt to make lazygit's overall UI look more "elegant",
but it has too many problems. The biggest is that we have panels that
combine two views (e.g. a prompt with suggestions below, or the commit
message editor with subject and body), and showing the frame of the
inactive half of these as "dim" looks wrong; we'd have to add a new
concept of a view that doesn't have the focus but otherwise still looks
active.

Also, some terminals don't do a good job of showing lines made of dim
box drawing characters (e.g. Zed's builtin terminal). This could
probably be improved by using a grey hex color instead of dim, but then
we'd have to pick one that looks good with all possible terminal color
scheme.

That's too many questions, and it's not important enough to spend time
on this, so just revert the change.
2026-09-30 08:38:27 +02:00
Stefan Haller a3fae72578 Make shift-backspace do the same as backspace in editors (#6078)
This can be useful when typing an all-caps word; if you mistype one
letter, you can delete it without having to let go of shift.

Closes #6076.
2026-09-28 22:52:10 +02:00
Stefan Haller 050803827e Make shift-backspace do the same as backspace in editors
This can be useful when typing an all-caps word; if you mistype one
letter, you can delete it without having to let go of shift.
2026-09-28 22:22:22 +02: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
Stefan Haller 5fcbdadc67 Avoid showing pull request icons with a delay on startup even though they are cached (#6049)
When lazygit starts up in a repo with many remote branches, the pull
request icons in the branches panel showed up a good while after the
branch list itself, even though the pull requests come from the cache
file and are in the model before the first render. In the repo where
this showed up (5400 remote branches) the icons were up to half a second
late.

The icons are rendered from Model.PullRequestsMap, and that map is built
from the remotes' URLs, because a branch's upstream remote tells us
which repo owner's pull requests to look for. The map therefore stays
empty until the remotes are in the model, and the remotes refresh
doesn't put them there until it has also enumerated and sorted all
remote branches. In a big repo that takes hundreds of milliseconds;
reading the remotes themselves takes ten.

Load the two separately, and put the remotes in the model as soon as
they have been read from the git config. The branches refresh then finds
them there, and the pull request icons are part of the first render of
the branch list.
2026-09-22 12:53:38 +02:00
Stefan HallerandClaude Opus 5 5a61f658a9 Let the branches refresh wait for the remotes
The pull request icons in the branches panel come from the remotes, so a
branches render that happens before the remotes are in the model shows no icons,
and the render that follows once the remotes land has to add them. The branches
refresh already waits for the worktrees for exactly this reason.

Wait for the remotes as well. They are read from the git config before their
branches are loaded, so the wait is over well before the branch load itself is
done, and the first render of the branch list shows the icons no matter which of
the two finishes first.

Start the remotes scope before the branches scope, so that an early return in
performRefresh can't leave the branches refresh waiting for a scope that was
never started. The worktrees scope goes first for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:52:35 +02:00
Stefan HallerandClaude Opus 5 1b662a8ec3 Put the remotes in the model before loading their branches
When lazygit starts up in a repo with many remote branches, the pull request
icons in the branches panel show up a good while after the branch list itself,
even though the pull requests come from the cache file and are in the model
before the first render. In the repo where this showed up (5400 remote
branches) the icons were up to half a second late.

The icons are rendered from Model.PullRequestsMap, and that map is built from
the remotes' URLs, because a branch's upstream remote tells us which repo
owner's pull requests to look for. The map therefore stays empty until the
remotes are in the model, and the remotes refresh doesn't put them there until
it has also enumerated and sorted all remote branches. In a big repo that takes
hundreds of milliseconds; reading the remotes themselves takes ten.

Load the two separately, and put the remotes in the model as soon as they have
been read from the git config. The branches refresh then finds them there, and
the pull request icons are part of the first render of the branch list.

Carry over the branches of the remotes we already have in the model in that
first update, so that the remote branches, and the branch counts in the remotes
panel, stay in place until the fresh ones are loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:48:22 +02:00
Stefan Haller 01ce5b0800 Add gopls to the nix dev shell (#6047)
Closes #6046.
2026-09-22 10:04:24 +02:00
Stefan Haller 7e3accb35f Add gopls to the nix dev shell 2026-09-21 22:37:37 +02:00
Stefan Haller f8499e3b49 Keep the outer folder selected when a folder path in the files panel splits up (#6037)
When only one file inside a nested folder has changes, the files panel
shows the whole path to it on a single line:

```
▼ a/b/c
   M file.txt
```

Selecting that line shows the diff of everything below it, in this case
the entire working tree. As soon as a second file changes in a different
folder, the panel splits that line up:

```
▼ a/b
  ▼ c
     M file.txt
  ▼ d
     M other_file.txt
```

Until now, the selection moved down to `c` in this situation, so the
main view suddenly showed only the diff of that folder. Now the
selection stays on the outer folder, `a/b` here. That is the same line
as before, and it still shows the diff of everything. This is most
useful if you like to keep the top folder selected to always see the
diff of the whole working tree; the same rule applies to folders further
down the tree. The reverse case, where two folders fold back into a
single line, already kept the selection on that line.

Along the way we also fixed two small bugs in how the selection follows
a rename after a refresh; see the individual commit messages for
details.
2026-09-20 17:22:45 +02:00
Stefan HallerandClaude Fable 5.1 c7f62ea9b6 Keep the topmost directory selected when a compressed directory splits
When a single file is modified inside a nested directory, the file tree
compresses the whole chain of directories into one line, such as
"pkg/gui/controllers/helpers". Selecting that line shows the diff of the
entire working tree. When a second file is then modified in another
subdirectory of pkg/gui, the tree splits the line into "pkg/gui" with
"context" and "controllers/helpers" below it, and the refresh moves the
selection down to "controllers/helpers". Users who keep the top
directory selected to see the diff of everything lose that view and
have to move the cursor back up after every such refresh.

This happens because the selection is re-found by the node's own path,
and a compressed node's path is the deepest directory in its chain. The
node stood for every directory in that chain, though, and the topmost
piece of the split is the one that stays on the same line.

Match a compressed directory node against any new node that stands for
at least one of the same directories. The list is in depth-first order,
so the topmost piece wins and the cursor stays on its line. Files are
never compressed, so their handling doesn't change. The reverse case,
where two directories fold back into one compressed line, already
selected the merged line and still does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 16:04:57 +02:00
Stefan HallerandClaude Fable 5.1 7d576c3d30 Expand to the new half of a rename after rebuilding the tree
When the deletion of the selected file is staged and git then reports
it as the old half of a rename whose new half sits in a collapsed
directory, the selection is meant to move to the rename. With
gui.showRootItemInFileTree turned on, the directory stays collapsed and
the selection lands on it. With the option turned off, the directory
expands, but if another file in it sorts before the rename, that file
gets selected.

The loop that expands the directory runs before the tree is rebuilt and
works on the file list instead of on tree nodes. It compares the
rename's previous path, a user-facing path, against the selected node's
internal path, so the two never match while the root item is shown. The
path it hands to ExpandToPath is user-facing as well, so the directory
would stay collapsed either way. And because the loop runs before the
old node list is captured, expanding a directory above the selection
shifts that list, and the search for the new selection starts from
whatever node moved into the selected line.

Rebuild the tree first, capture the old node list before anything
expands, and then look for the rename among the leaves of the new tree.
ExpandToPath gets the leaf's own internal path, so the two kinds of
paths never need converting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 16:03:49 +02:00
Stefan HallerandClaude Fable 5.1 b4b6a993ea Add a test for following a file into a rename in a collapsed directory
When the deletion of the selected file is staged and git then reports
it as the old half of a rename whose new half sits in a collapsed
directory, the selection is meant to move to the rename, expanding the
directory on the way. With gui.showRootItemInFileTree turned on, the
directory stays collapsed and the selection lands on it instead. With
the option turned off, the directory does expand, but if another file
in it sorts before the rename, that file gets selected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 16:03:05 +02:00
Stefan HallerandClaude Fable 5.1 4766ce009e Compare user-facing paths when re-finding the selection after a refresh
When the selected rename splits into its two halves, e.g. because it was
unstaged, the selection is meant to move to the new half. With the
default setting of gui.showRootItemInFileTree, it lands on the file that
follows the rename in the list instead.

findNewSelectedIdx identifies a rename by the names of its two halves.
These are user-facing paths, without the "./" prefix that the root item
adds to every internal path, but they were compared against internal
paths. The comparison never matched while the root item was shown.

Compare user-facing paths throughout findNewSelectedIdx. Node.ID already
identifies list items by their user-facing path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:32:33 +02:00
Stefan HallerandClaude Fable 5.1 77cbb532f2 Add a test for the selection after a selected rename splits in two
When a rename is selected in the files panel and then splits into its
two halves, e.g. because it was unstaged, the selection is meant to move
to the new half. This only works with gui.showRootItemInFileTree turned
off. With the default setting, the selection lands on the file that
follows the rename in the list instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:31:38 +02:00
Stefan Haller 3e811e51ee Remove codespell again (#6028)
It was added in #3751 with the explicit reservation that we remove it
again when it turns out to be annoying. This is actually the case now; I
don't find catching spelling errors valuable enough to warrant the
nuisance of having to silence false positives. (And AI agents don't make
spelling mistakes anyway :-)
2026-09-19 21:49:34 +02:00
Stefan Haller 6c67fc24df Remove codespell again
It was added in #3751 with the explicit reservation that we remove it
again when it turns out to be annoying. This is actually the case now; I
don't find catching spelling errors valuable enough to warrant the
nuisance of having to silence false positives. (And AI agents don't make
spelling mistakes anyway :-)
2026-09-19 21:45:54 +02:00
Stefan Haller 1ae35ddb78 README.md: Update Sponsors (#5462)
Automated changes by
[create-pull-request](https://github.com/peter-evans/create-pull-request)
GitHub action
2026-09-19 20:41:01 +02:00
github-actions[bot] 7eeda1514b README.md: Update Sponsors 2026-09-19 16:33:07 +00:00
Stefan Haller ea377d44ed List stacked branches in stack order (#6024)
With stacked branches it is common to rebase the whole stack in one go,
and when doing that it is likely that all branch tips get the same
committer date (because git's time stamps have a granularity of one
second). We sort branches by committer date by default, and git sorts
branches with the same committer date alphabetically, which causes
stacked branches to be listed out of order.

To improve this, detect groups of branches that share the same committer
date, and sort them by ancestry, so that a branch comes before the
branches it is based on. This takes one extra for-each-ref call (only
when such a group exists), and it requires git 2.41 or later.
2026-09-19 18:32:53 +02:00
Stefan HallerandClaude Opus 5 afb66f78a9 Sort remote branches with the same committer date in stack order
Force-pushing a stack of branches puts the same commits on the remote,
so the remote branches panel shows the same alphabetical order for a
stack that the local branches panel did. Sort them by ancestry too,
which needs the tip hash and committer date of each remote branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 18:10:59 +02:00
Stefan HallerandClaude Opus 5 b915850300 Load remote branches as one list and group them by remote afterwards
Sorting the branches of a remote by ancestry works on the whole list, so
build the list first and group it by remote once it is in the right
order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 18:10:59 +02:00
Stefan HallerandClaude Opus 5 9123a40135 Hand the RemoteLoader its GitCommon
It needs the git version to decide whether the ahead-behind format is
available, and GitCommon already carries the common state and the command
builder that the loader used to take separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 18:10:59 +02:00
Stefan HallerandClaude Opus 5 effe661e09 Sort local branches with the same committer date in stack order
In date order, the branches panel lists a stack of branches
alphabetically rather than from the top of the stack down. Rebasing a
stack creates all of its commits within the same second, so all of its
branch tips end up carrying the same committer date, and git sorts refs
with equal committer dates by name.

Sort each group of branches that share a committer date by ancestry
instead, so that a branch comes before the branches it is based on.
Branches that are not descended from one another keep the alphabetical
order git gave them. Determining the ancestry takes one more
for-each-ref call, and it only runs when there is a group to sort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 18:10:59 +02:00
Stefan HallerandClaude Opus 5 1441359ccc Keep the positions of malformed ahead-behind fields
parseAheadBehindForEachRefOutput dropped fields it couldn't parse, which
left the remaining ones of that line pointing at the wrong bases. The
caller that picks the closest base doesn't care, but sorting refs by
ancestry has to know which base a pair of numbers belongs to. Return an
entry per base and mark the ones that were malformed, as the function's
comment has claimed all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:48:35 +02:00
Stefan HallerandClaude Opus 5 36192a0495 Let the caller of buildAheadBehindForEachRefArgs choose the refs
The function always asked about every ref under refs/heads. Sorting refs
by ancestry needs the ahead-behind values of a handful of named refs, and
for remote branches those live under refs/remotes, so take the patterns
as an argument. The bases can be commit hashes as well as ref names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:47:00 +02:00
Stefan HallerandClaude Opus 5 980a0cc65b Move the ahead-behind for-each-ref helpers to their own file
Building and parsing `git for-each-ref --format=%(ahead-behind:<base>)`
is not specific to loading local branches; the commits below use it to
sort refs by ancestry, from the remote branch loader too. Give these
helpers and their tests a file of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:45:32 +02:00
Stefan Haller a1f1102054 Fix the Commits panel's column widths when scrolling during interactive rebase or bisect (#6022)
The commit list only renders the visible rows for performance reasons
(see #3687), which means it also uses only the visible rows for
determining the widths of the list's columns. In an interactive rebase,
when you scroll down so that no rebase todos are visible any more, the
author and subject columns would snap to the left, which looks ugly and
distracting. A similar thing happened in half-screen mode when only
commits from today are visible: today's commits use a shorter date
format, so as you scroll down to make an older commit visible, the
columns to the right of the date would move to the right to make room
for the longer date.

Fix this by taking the width requirements of _all_ commits into account,
not just the visible ones, and pad the column texts to those widths. For
the date we take a shortcut: measuring the widths of all commits would
be too expensive, so we only format the oldest commit's date, on the
assumption that this one is the least likely to be from today. This only
works when the long time format is actually longer than the short one,
and when the long format always results in the same width for all dates;
both of these assumptions might be false when users reconfigure their
`gui.timeFormat` or `gui.shortTimeFormat` configs (e.g. to include a
week day), but for the default values of these it works.
2026-09-19 10:59:41 +02:00
Stefan HallerandClaude Opus 5 1d67763b7e Reserve the width the whole reflog needs for its date column
Pad the date of every line to the width that the oldest entry in the
reflog asks for, so that the column keeps its width as the user scrolls.
This is the same treatment the commits panel's date column gets, with the
same limits; getReservedColumnWidths spells them out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 7cf7b51467 Demonstrate that the reflog's date column is as wide as the visible lines need
The reflog panel renders only the lines that are on screen too, so the
expanded panel's date column is only as wide as gui.shortTimeFormat while
nothing but entries from today is visible, and the message of every entry
jumps to the left. A reflog reaches back in time, so scrolling crosses
that boundary soon enough.

GetReflogCommitListDisplayStrings had no tests at all, so cover the two
shapes it can return along the way.

This file is deliberately left un-gofumpt'd: gofumpt indents the body of
the commented-out block by a tab, which would fill the diff of the commit
that swaps the two blocks with whitespace-only changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 0720a05ae4 Hand the whole reflog to GetReflogCommitListDisplayStrings
The reflog commits context slices the visible lines out itself and passes
only those, so the function has no way to look at the rest of the list. A
following commit needs the oldest entry to work out how much width the
date column needs. Take the whole list along with the range to render, the
way GetCommitListDisplayStrings already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 410d7209b1 Reserve the width that all commits need for a commit list's columns
During an interactive rebase, scrolling down far enough that the pending
todos leave the screen makes the author and subject of every commit jump
to the left; scrolling back up makes them jump back. During a bisect the
column that holds "<-- current" and "?" comes and goes the same way, and
in the expanded commits panel the date column is only as wide as
gui.shortTimeFormat while nothing but today's commits is on screen.

A column is as wide as the widest string in it, and a column whose strings
are all empty is dropped altogether. The commits and sub-commits panels
render only the lines that are on screen, so those widths come from the
visible lines alone and follow the scroll position.

Pad the hash, bisect, action and date cell of every line to the width that
all the commits in the list need. A padded cell is no longer empty, so its
column is never dropped, and the column is already as wide as the whole
list needs, so its width no longer depends on what is visible.

The date column is the exception. Formatting the date of every commit on
every render costs too much, so it measures the oldest commit only. That
covers the conventional time formats; getReservedColumnWidths says what it
misses, and why it can never reserve width that no commit asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 101424322a Demonstrate that a commit list's columns are as wide as the visible lines need
The commits panel renders only the lines that are on screen, so the width
of a column follows what happens to be visible, and a column all of whose
visible lines are empty disappears altogether. Scrolling past the pending
rebase todos takes the action column away with them, and everything to its
right jumps to the left.

Cover the same problem for two more columns: the hash column, which goes
away while only hashless todos such as "update-ref" are on screen, and the
date column in the expanded commits panel, which is as narrow as the time
format while only commits from today are on screen.

This file is deliberately left un-gofumpt'd: gofumpt indents the body of
the commented-out block by a tab, which would fill the diff of the commit
that swaps the two blocks with whitespace-only changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 56c2ffb8f8 Don't strip leading spaces from a scenario's expected output
formatExpected runs the expected output through strings.TrimSpace to get
rid of the newlines that the raw string literal starts and ends with, but
that also eats the indentation of the first line. A scenario whose first
line starts with an empty column can't express what it expects, and a
later commit adds two of those. Trim the newlines only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 43e63bcc24 Return early from WithPadding when there is nothing to pad to
A caller that asks for a padding of zero gets its string back unchanged,
but only after WithPadding has measured it, and measuring means a
Decolorise lookup and a width scan over the result. A later commit pads
four cells of every commit in the list, and asks for a padding of zero
for all four whenever no rebase or bisect is in progress and the date
column is hidden, so make that case cost nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:16:04 +02:00
Stefan HallerandClaude Opus 5 53e9c73034 Separate the text of a commit's hash, bisect and action cells from its styling
These three cells are built and styled in one step, so their width can
only be measured by stripping the styling off again. A later commit needs
those widths to reserve space for the columns they go into. Pull the text
out into getHashText, getActionText and a getBisectStatusText that no
longer styles what it returns, and apply the styling at the call site.

A commit with no hash, such as a "break" or "update-ref" todo, now gets an
empty hash cell instead of one holding nothing but colour codes. Nothing
can see the difference: both render as the same number of blank columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:54:36 +02:00
Stefan HallerandClaude Opus 5 939bf831fb Cache Decolorise's result for a string that decolorises to nothing
Decolorise stores its result in a cache keyed by the string it was given,
but the lookup treats an empty result as a miss, so a string that
decolorises to nothing is recomputed on every call. Recomputing it means
compiling two regexes, and that costs 4534ns against 24.7ns for a string
the cache does answer for.

Empty cells are everywhere in the list panels, and RenderDisplayStrings
measures every cell twice, once to work out the column widths and once to
pad it. A commits panel showing 50 lines with one empty column spends
about 0.45ms of every render on this.

Read the cache with the two-value form, so that an empty result counts as
an answer, and compile the two regexes once at package level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:54:36 +02:00
Stefan Haller 71d3e7dfa5 Improve the Recent Repos menu (#6016)
Several improvements to the Recent Repos menu:

- use the full available width for a menu (previously we would use a
ratio of the window width, which would often be less than the 90
characters that are the default window width)
- truncate long repo/worktree names and long branch names to at most 30
characters, so that all three columns of the menu fit
- abbreviate the home directory in paths to `~`; this is easier to read,
and saves some space
- for detached heads, show "HEAD detached at <sha>" rather than just the
sha, like we do in the worktrees panel
- make detached heads show properly for submodules; these would
previously show "Branch unknown"
- show correct branch name for reftable repos (these would show
".invalid")
2026-09-14 13:47:49 +02:00
Stefan HallerandClaude Opus 5 3868a9407b Ask git for the branch when the HEAD file doesn't hold it
Reading HEAD is worth keeping for the repos it can answer for, because
the menu opens on a keystroke and asking git costs a process per entry.
So go to git only for the placeholder, and for anything else the read
can't make sense of. That last part also gets the menu an answer for
layouts we don't know about yet, where it used to give up and say
"Branch unknown".

Git needs two commands to cover every repo. `git symbolic-ref` names the
branch even when it has no commit yet; `git rev-parse` resolves a
detached HEAD, and fails on a branch without a commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:49:29 +02:00
Stefan HallerandClaude Opus 5 339b4c4555 Export ForOtherRepo
The recent repos helper is about to run a git command against each repo
in the menu, and it needs the same treatment of GIT_DIR and GIT_WORK_TREE
that everything else pointed at another repo gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:49:29 +02:00
Stefan HallerandClaude Opus 5 7b5c6f4e12 Demonstrate that the recent repos menu shows ".invalid" for a reftable repo
A repo that keeps its refs in a reftable shows ".invalid" in the branch
column. Git stores the real HEAD in a binary table there and leaves
"ref: refs/heads/.invalid" in the HEAD file, so that anything still
reading the HEAD file fails loudly instead of getting a stale answer.
We read that file and take the placeholder for a branch name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:49:29 +02:00
Stefan HallerandClaude Opus 5 93713b5401 Resolve a relative gitdir against the repo it belongs to
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:49:29 +02:00
Stefan HallerandClaude Opus 5 00c0c4c061 Demonstrate that the recent repos menu can't read a submodule's branch
The menu shows "Branch unknown" for every submodule, whatever it has
checked out. A submodule has no .git directory; its .git is a file
naming the directory, and for a submodule git writes that name relative
to the submodule. We hand it to os.ReadFile unchanged, so it resolves
against lazygit's own working directory and the read fails. A worktree
created with --relative-paths (or with worktree.useRelativePaths set)
gets a relative name too, and fails the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:49:29 +02:00
Stefan HallerandClaude Opus 5 9e542ff6e0 Show "HEAD detached at <hash>" in the recent repos menu
A repo with no branch checked out puts a bare short hash in the branch
column, where it reads as a branch name. Spell it out the way the
worktrees panel already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:18:21 +02:00
Stefan HallerandClaude Opus 5 a3e940e20e Pull the reading of a repo's HEAD out of getCurrentBranch
The next commits change what the recent repos menu shows for a repo
without a branch, and teach it about layouts whose HEAD it reads wrongly
today. Give the reading a function of its own first, one that reports
what it found rather than what to display. This keeps the later changes
apart from each other, and it lets the tests call the reading directly
instead of going through a ReposHelper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:16:16 +02:00
Stefan HallerandClaude Opus 5 8d0f81cc15 Make the worktrees panel's detached-head text translatable
The recent repos menu is about to show the same text for a repo that has
no branch checked out. Hardcoding the English a second time would leave
translators with one of the two copies, so give the text a translation
key and take it from there in both places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:13:39 +02:00
Stefan HallerandClaude Opus 5 3452d41fe2 Truncate the path column of the recent repos menu too
The path column is the last one, so nothing pushes it aside; instead it
runs off the right edge of the menu itself, and the reader has no way of
telling that there is more to it. Give it a maximum width as well, sized
so that the three columns and the spaces between them fill the menu at
its widest.

A path loses its middle rather than its end, because the directory that
immediately contains the repo says more about where it is than the root
of the tree does. The whole path, with the home directory still
abbreviated, joins the names in the tooltip when it doesn't fit.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 49c267d0da Give the maximum width of a menu a name
The recent repos menu is about to size its columns to fit into a menu of
that width.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 9f7117398a Add a truncation function that puts the ellipsis in the middle
TruncateWithEllipsis cuts off the end of a string, which is the wrong
end for a path: what distinguishes two paths is often the last segment,
and it is the one thing the reader wants to see. Keep both ends and put
the ellipsis between them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 fd5c703e1e Truncate the repo and branch names in the recent repos menu
Each column of a menu is padded to the width of its widest entry, so a
single long name in the first two columns of the recent repos menu
pushes the path column off the right edge for every entry. Anyone whose
worktree directories are named after their branches hits this: with a
90 column menu and one 42 character name, the path column starts at
column 86 of 88.

Truncate both names to 30 characters, and put the ones that got
truncated into the item's tooltip, so that the full text is still on
screen for the selected entry. Filtering keeps matching the full names
and the full path, which the columns no longer show in their entirety.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 b11651820a Show the containing directory instead of the full path in the recent repos menu
The third column of the recent repos menu spells out the full path of
each repo. That repeats the directory name which the first column
already shows, and it writes out the home directory in full. Both are
wasted width in a menu that is limited to 90 columns; the path column is
the first thing to run off the right edge, and users who don't know that
'L' scrolls the menu horizontally never see it at all.

Show the directory that contains the repo instead, with the home
directory abbreviated to '~'. For a list of 106 recent repos this takes
the column from 111 characters down to 97 at its longest, and from 47
down to 28 in the median.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 b37018a936 Collect the current branches of the recent repos in a slice
Nothing needs to look up the branch of a repo by its path, so a slice
indexed like the list of paths does the job, and its elements are plain
strings instead of the values of type "any" that a sync.Map hands back.
The next commits measure and truncate the branch name, which needs a
string.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 7253a45c21 Let a popup panel have an odd width
The left and the right edge of a popup panel were each derived by
halving the panel's width, so a panel that asked for an odd width lost a
column and came out one column left of centre. Resizing the window then
moved the panel's right edge only every other column, and left a gap of
one column between the panel and where it should end half of the time.

Derive the right edge from the left one and the width instead, the way
the branch above already does it for a popup that has a parent. This
also means that a panel of an odd width is now as wide as the width its
text was wrapped to.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan HallerandClaude Opus 5 d0fa50d4ef Give popup panels their full width whenever the window has room for it
The width of a popup panel was a ratio of the window's width, capped at
the panel's maximum. The ratio never did anything for confirmations and
prompts, whose minimum and maximum widths are both 80; they always came
out at 80, or at the window width if that was narrower. For menus and
the commit message editor it meant that the window had to be 158 columns
wide before either of them reached its maximum width, and that below 140
columns they were 80 columns wide. This is narrower than the window has
room for, and too narrow for the three columns of the recent repos menu.

Give every panel the width it asks for as long as it fits, and shrink it
only when the window leaves no choice. A panel keeps a margin of three
columns on either side while it can afford to, so that it doesn't sit
flush against the sides of the window as soon as the window gets a
little narrow.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:23:26 +02:00
Stefan Haller 17cb09fa7b Avoid loading or migrating the user config in daemon mode (#6001)
When performing an interactive rebase, a child process of lazygit is
started as a rebase-todo editor, and sets (for example) the todo to
"edit" that you want to stop at. This child process would load the user
config as a normal lazygit does, and try to migrate it if it's out of
date. This is a problem when you maintain your dot files including
lazygit's config file in a git repo; if you rewrite the history of that
repo, and stop at a commit before the last config migration, the child
process tried to migrate the config mid-rebase, leading to confusing
behavior. Avoid this; there's no reason for the child process to load
the user config at all, it doesn't need it.

Note that if you stop in the rebase (e.g. because of a conflict), the
normal focus-in refresh of the lazygit main process will still try to
migrate the config at that time. This is the correct and expected
behavior, nothing to fix there; the file will show up as modified in the
Files panel, and needs to be discarded manually there before continuing.
2026-09-13 07:41:42 +02:00
Stefan Haller 4797734a1d Handle daemon mode before creating the app config
Daemon mode doesn't have any reason to read the user config or try to
migrate it if it's old.
2026-09-13 07:39:02 +02:00
Stefan Haller a54b3f0279 Construct logger separately and pass it into NewCommon
This is a preparation for passing only the logger to daemon.Handle
instead of the whole common.
2026-09-13 07:39:02 +02:00
Stefan Haller afbde5be3c Pass only log to daemon.Handle, not the whole common
The log is the only thing it needs.
2026-09-13 07:39:02 +02:00
Stefan Haller aac8bd4676 Fix Esc being handled with significant delay on some terminals (#6011)
Bump tcell to v3.5.0; this fixes a regression with Esc being handled
with a significant delay on some terminals.
2026-09-13 07:37:27 +02:00
Stefan Haller 70cfc565e7 Bump tcell to v3.5.0
This fixes a regression with Esc being handled with a significant delay
on some terminals.
2026-09-11 19:13:17 +02:00
Stefan Haller d0ede21e9c Allow running tests from a tarball (#6003)
Running integration tests from a tarball was never possible, but running
unit tests (`go test ./... -short`) was; this broke with v0.64.1
(specifically, with 34da956f5d). Make that possible again, and for all
tests now, including integration tests.
2026-09-09 09:30:10 +02:00
Stefan Haller 8b049be31d Find the lazygit root directory by go.mod instead of .git
Running the tests in an exported source tarball fails with "must run in
lazy project folder or child folder". GetLazyRootDirectory searches the
working directory and its parents for a .git directory, and a tarball
doesn't have one. This has always affected the integration tests; since
34da956f5d a unit test calls the function too, so now even
`go test ./... -short` fails.

Search for the go.mod file that declares lazygit's module instead. It
ships in tarballs, and there is exactly one of it per source tree.

Put the function in our own pkg/utils rather than change lazycore's; the
criterion is specific to lazygit, and I don't feel like making a change
to lazycore.

Return an error rather than call log.Fatal, and report it from the two
callers that run under `go test`. In a test binary, log.Fatal exits
without attributing the failure to any test. That is the failure mode
34da956f5d set out to remove. The remaining callers are development
tools that have nothing useful to do without the root directory; they
keep exiting, now through MustFindLazygitRootDirectory.

Also stop the search at the root of the file system rather than at "/".
On Windows the old loop walks up to "C:\" and then spins there forever.
2026-09-09 09:27:44 +02:00
Stefan Haller a8dc4aaf1b AGENTS.md addition 2026-09-07 09:51:49 +02:00
253 changed files with 15343 additions and 1847 deletions
-7
View File
@@ -1,7 +0,0 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,go.sum,*.lock,.codespellrc,vendor,translations,Keybindings_*.md,./pkg/gocui
check-hidden = true
# camel-cased
ignore-regex = (\b[A-Za-z][a-z]*[A-Z]\S+\b|\.edn\b|\S+…|\\nd\b)
ignore-words-list = fomrat,inbetween
-25
View File
@@ -1,25 +0,0 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@9ba2c57125d4908eade4308f32c4ff814c184633 # v1.2.0
- name: Codespell
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
+4
View File
@@ -120,6 +120,10 @@ separate, reviewable commit that the user decides when to fold in. A bare
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception.
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
you naturally would because "the branch isn't pushed yet". The user always wants
to review what you changed, so make this transparent; no exceptions.
**When the tip is the wrong place for a fixup, insert it mid-branch.**
Committing a fixup at the tip of the branch only works while the code it
touches still looks the same there; once later commits have rewritten that
+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()
}
+89 -22
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:
@@ -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
@@ -817,6 +845,8 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
@@ -999,6 +1029,7 @@ The available attributes are:
- bold
- default
- dim # faint text; not supported by every terminal
- reverse # useful for high-contrast
- underline
- strikethrough
@@ -1023,28 +1054,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 +1117,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.
+17 -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 | |
@@ -223,6 +223,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
@@ -328,6 +335,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
+14
View File
@@ -192,6 +192,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
@@ -305,6 +312,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
+17 -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'. |
@@ -84,6 +84,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
@@ -162,6 +169,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
@@ -223,7 +237,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 | |
+15 -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 | |
@@ -231,6 +231,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
@@ -306,6 +313,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
+14
View File
@@ -99,6 +99,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
@@ -201,6 +208,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
+14
View File
@@ -235,6 +235,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
@@ -337,6 +344,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
+17 -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'. |
@@ -74,6 +74,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -106,6 +113,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -223,7 +237,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 `` | Просмотреть параметры сброса | |
@@ -286,6 +286,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -334,6 +341,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -75,6 +75,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
@@ -356,6 +363,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
+1
View File
@@ -97,6 +97,7 @@
# Go toolchain
go
gotools
gopls
# Development tools
git
+1 -1
View File
@@ -16,7 +16,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.2
github.com/gdamore/tcell/v3 v3.5.0
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
+2 -2
View File
@@ -30,8 +30,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.2 h1:gGW+6z2Bz5Wl2mNwFlm9+eRmg2JQrWcKjSkL1LRfpNU=
github.com/gdamore/tcell/v3 v3.4.2/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
github.com/gdamore/tcell/v3 v3.5.0 h1:SCp9czLv2K2aPORgD6+4fjV0xNAzKIkiezCkp6bHLe4=
github.com/gdamore/tcell/v3 v3.5.0/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+3 -4
View File
@@ -61,10 +61,9 @@ func Run(
}
}
func NewCommon(config config.AppConfigurer) (*common.Common, error) {
func NewCommon(config config.AppConfigurer, log *logrus.Entry) (*common.Common, error) {
userConfig := config.GetUserConfig()
appState := config.GetAppState()
log := newLogger(config)
// Initialize with English for the time being; the real translation set for
// the configured language will be read after reading the user config
tr := i18n.EnglishTranslationSet()
@@ -80,8 +79,8 @@ func NewCommon(config config.AppConfigurer) (*common.Common, error) {
return cmn, nil
}
func newLogger(cfg config.AppConfigurer) *logrus.Entry {
if cfg.GetDebug() {
func NewLogger(debug bool) *logrus.Entry {
if debug {
logPath, err := config.LogPath()
if err != nil {
log.Fatal(err)
+21 -22
View File
@@ -3,14 +3,13 @@ package daemon
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
@@ -66,14 +65,14 @@ func getInstruction() Instruction {
return mapping[getDaemonKind()](jsonData)
}
func Handle(common *common.Common) {
func Handle(log *logrus.Entry) {
if !InDaemonMode() {
return
}
instruction := getInstruction()
if err := instruction.run(common); err != nil {
if err := instruction.run(log); err != nil {
log.Fatal(err)
}
}
@@ -107,7 +106,7 @@ type Instruction interface {
SerializedInstructions() string
// runs the instruction
run(common *common.Common) error
run(log *logrus.Entry) error
}
func serializeInstruction[T any](instruction T) string {
@@ -147,7 +146,7 @@ func (self *ExitImmediatelyInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ExitImmediatelyInstruction) run(common *common.Common) error {
func (self *ExitImmediatelyInstruction) run(log *logrus.Entry) error {
return nil
}
@@ -165,8 +164,8 @@ func (self *RemoveUpdateRefsForCopiedBranchInstruction) SerializedInstructions()
return serializeInstruction(self)
}
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return nil
})
}
@@ -193,8 +192,8 @@ func (self *ChangeTodoActionsInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *ChangeTodoActionsInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange {
return utils.TodoChange{
Hash: c.Hash,
@@ -225,8 +224,8 @@ func (self *DropMergeCommitInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *DropMergeCommitInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *DropMergeCommitInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.DropMergeCommit(path, self.Hash, getCommentChar())
})
}
@@ -256,8 +255,8 @@ func (self *MoveFixupCommitDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *MoveFixupCommitDownInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveFixupCommitDown(path, self.OriginalHash, self.FixupHash, self.ChangeToFixup, getCommentChar())
})
}
@@ -282,14 +281,14 @@ func (self *MoveTodosUpInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosUpInstruction) run(common *common.Common) error {
func (self *MoveTodosUpInstruction) run(log *logrus.Entry) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(common, func(path string) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
@@ -314,14 +313,14 @@ func (self *MoveTodosDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosDownInstruction) run(common *common.Common) error {
func (self *MoveTodosDownInstruction) run(log *logrus.Entry) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(common, func(path string) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}
@@ -340,8 +339,8 @@ func (self *InsertBreakInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *InsertBreakInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *InsertBreakInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return utils.PrependStrToTodoFile(path, []byte("break\n"))
})
}
@@ -364,8 +363,8 @@ func (self *WriteRebaseTodoInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *WriteRebaseTodoInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
func (self *WriteRebaseTodoInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
return os.WriteFile(path, self.TodosFileContent, 0o644)
})
}
+5 -5
View File
@@ -5,9 +5,9 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -17,9 +17,9 @@ type ChangeTodoAction struct {
Flag string
}
func handleInteractiveRebase(common *common.Common, f func(path string) error) error {
common.Log.Info("Lazygit invoked as interactive rebase demon")
common.Log.Info("args: ", os.Args)
func handleInteractiveRebase(log *logrus.Entry, f func(path string) error) error {
log.Info("Lazygit invoked as interactive rebase demon")
log.Info("args: ", os.Args)
path := os.Args[1]
if strings.HasSuffix(path, "git-rebase-todo") {
@@ -32,7 +32,7 @@ func handleInteractiveRebase(common *common.Common, f func(path string) error) e
// if we are rebasing and squashing, we'll see a COMMIT_EDITMSG
// but in this case we don't need to edit it, so we'll just return
} else {
common.Log.Info("Lazygit demon did not match on any use cases")
log.Info("Lazygit demon did not match on any use cases")
}
return nil
+10 -6
View File
@@ -93,6 +93,15 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
env.SetGitDirEnv(cliArgs.GitDir)
}
// The log file lives in the config dir, so this must come after setting the
// CONFIG_DIR env var above.
logger := NewLogger(cliArgs.Debug)
if daemon.InDaemonMode() {
daemon.Handle(logger)
return
}
if cliArgs.PrintVersionInfo {
gitVersion := getGitVersionInfo()
fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s, git version=%s\n", buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, buildInfo.Version, runtime.GOOS, runtime.GOARCH, gitVersion)
@@ -154,16 +163,11 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
appConfig.SaveGlobalUserConfig()
}
common, err := NewCommon(appConfig)
common, err := NewCommon(appConfig, logger)
if err != nil {
log.Fatal(err)
}
if daemon.InDaemonMode() {
daemon.Handle(common)
return
}
if cliArgs.Profile {
go func() {
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
+4 -3
View File
@@ -19,12 +19,12 @@ import (
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -49,7 +49,7 @@ func CommandToRun() string {
}
func GetKeybindingsDir() string {
return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings"
}
func generateAtDir(cheatsheetDir string) {
@@ -58,10 +58,11 @@ func generateAtDir(cheatsheetDir string) {
log.Fatal(err)
}
mConfig := config.NewDummyAppConfig()
logger := app.NewLogger(mConfig.GetDebug())
for lang := range translationSetsByLang {
mConfig.GetUserConfig().Gui.Language = lang
common, err := app.NewCommon(mConfig)
common, err := app.NewCommon(mConfig, logger)
if err != nil {
log.Fatal(err)
}
+2 -2
View File
@@ -145,11 +145,11 @@ 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)
remoteLoader := git_commands.NewRemoteLoader(cmn, cmd)
remoteLoader := git_commands.NewRemoteLoader(gitCommon)
worktreeLoader := git_commands.NewWorktreeLoader(gitCommon)
stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd)
+103
View File
@@ -0,0 +1,103 @@
package git_commands
import (
"strconv"
"strings"
"github.com/samber/lo"
)
// Holds parsed values from a single %(ahead-behind:<base>) field.
type aheadBehind struct {
ahead, behind int
valid bool
}
type branchAheadBehind struct {
refName string
aheadBehinds []aheadBehind
}
// Parses output produced by:
//
// git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries, so
// that the entries of a line stay aligned with the bases.
func parseAheadBehindForEachRefOutput(
output string,
numBases int, // number of %(ahead-behind:...) tokens
) []branchAheadBehind {
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
result := make([]branchAheadBehind, 0, len(lines))
for _, line := range lines {
cols := strings.Split(line, "\x00")
if len(cols) != numBases+1 {
continue
}
refName := cols[0]
aheadBehinds := lo.Map(cols[1:], func(col string, _ int) aheadBehind {
return parseAheadBehindField(col)
})
entry := branchAheadBehind{
refName: refName,
aheadBehinds: aheadBehinds,
}
result = append(result, entry)
}
return result
}
func parseAheadBehindField(s string) aheadBehind {
parts := strings.Fields(s)
if len(parts) != 2 {
return aheadBehind{}
}
ahead, err1 := strconv.Atoi(parts[0])
behind, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return aheadBehind{}
}
return aheadBehind{ahead: ahead, behind: behind, valid: true}
}
// Picks the "closest" base by smallest ahead value (commits the branch
// has that the base doesn't = roughly "since fork point") and returns
// its behind value.
// Ties are broken by index order
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
validOnes := lo.Filter(aheadBehinds, func(ab aheadBehind, _ int) bool {
return ab.valid
})
return lo.MinBy(validOnes, func(a, b aheadBehind) bool {
return a.ahead < b.ahead
}).behind
}
// Builds a for-each-ref command that reports, for each ref matched by one of
// refPatterns, how far it is ahead and behind each of the bases. A base is a
// ref name or a commit hash. The output format is:
//
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
//
// with one ahead-behind field per base, in the same order as bases.
//
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
func buildAheadBehindForEachRefArgs(bases []string, refPatterns []string) []string {
formatParts := make([]string, 0, 1+len(bases))
formatParts = append(formatParts, "%(refname)")
for _, base := range bases {
formatParts = append(formatParts, "%(ahead-behind:"+base+")")
}
format := strings.Join(formatParts, "%00")
return NewGitCmd("for-each-ref").
Arg("--format=" + format).
Arg(refPatterns...).
ToArgv()
}
@@ -0,0 +1,262 @@
package git_commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseAheadBehindForEachRefOutput(t *testing.T) {
type scenario struct {
testName string
input string
numBases int
expected []branchAheadBehind
}
scenarios := []scenario{
{
testName: "single branch single base",
input: "refs/heads/feat\x002 5\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5, valid: true}},
},
},
},
{
testName: "multiple branches multiple bases",
input: "refs/heads/feat\x002 5\x0010 1\n" +
"refs/heads/main\x000 0\x000 0\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5, valid: true},
{ahead: 10, behind: 1, valid: true},
},
},
{
refName: "refs/heads/main",
aheadBehinds: []aheadBehind{
{ahead: 0, behind: 0, valid: true},
{ahead: 0, behind: 0, valid: true},
},
},
},
},
{
testName: "empty ahead-behind field for unreachable base",
input: "refs/heads/feat\x00\x002 5\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{},
{ahead: 2, behind: 5, valid: true},
},
},
},
},
{
testName: "ref name containing slashes and dashes",
input: "refs/heads/feat/foo-bar\x001 2\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat/foo-bar",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
{
testName: "trailing newline and blank lines are ignored",
input: "refs/heads/feat\x001 2\n\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
{
testName: "line with wrong column count is skipped",
input: "refs/heads/good\x001 2\n" +
"refs/heads/bad\n" +
"refs/heads/also_good\x003 4\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/good",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
{
refName: "refs/heads/also_good",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}},
},
},
},
{
testName: "malformed ahead-behind field becomes invalid but line is kept",
input: "refs/heads/feat\x00not_a_number\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{}},
},
},
},
{
testName: "empty input",
input: "",
numBases: 1,
expected: nil,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := parseAheadBehindForEachRefOutput(s.input, s.numBases)
assert.Equal(t, s.expected, result)
})
}
}
func TestSelectBehindForBranch(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
expected int
}
scenarios := []scenario{
{
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}},
expected: 7,
},
{
testName: "multi-base, clear winner by ahead",
aheadBehinds: []aheadBehind{
{ahead: 50, behind: 10, valid: true}, // master
{ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead
},
expected: 2,
},
{
testName: "develop forked from master case (ancestor-of-each-other)",
// feat-x has 5 commits since fork from develop.
// develop is 50 commits ahead of master.
// ahead vs master = 5 + 50 = 55; behind vs master = 0
// ahead vs develop = 5; behind vs develop = 5
aheadBehinds: []aheadBehind{
{ahead: 55, behind: 0, valid: true}, // master
{ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead
},
expected: 5,
},
{
testName: "tie on ahead - first base wins (config order)",
aheadBehinds: []aheadBehind{
{ahead: 5, behind: 10, valid: true}, // first
{ahead: 5, behind: 99, valid: true}, // second, same ahead
},
expected: 10,
},
{
testName: "first base invalid, second valid",
aheadBehinds: []aheadBehind{
{},
{ahead: 3, behind: 8, valid: true},
},
expected: 8,
},
{
testName: "all invalid - returns 0",
aheadBehinds: []aheadBehind{{}, {}},
expected: 0,
},
{
testName: "empty - returns 0",
aheadBehinds: nil,
expected: 0,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := selectBehindForBranch(s.aheadBehinds)
assert.Equal(t, s.expected, result)
})
}
}
func TestBuildAheadBehindForEachRefArgs(t *testing.T) {
type scenario struct {
testName string
bases []string
refPatterns []string
expected []string
}
scenarios := []scenario{
{
testName: "single base",
bases: []string{"refs/heads/master"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)",
"refs/heads",
},
},
{
testName: "two bases",
bases: []string{"refs/heads/master", "refs/remotes/origin/develop"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)",
"refs/heads",
},
},
{
testName: "four bases",
bases: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/a)%00%(ahead-behind:refs/heads/b)%00%(ahead-behind:refs/heads/c)%00%(ahead-behind:refs/heads/d)",
"refs/heads",
},
},
{
testName: "commit hashes as bases, individual refs as patterns",
bases: []string{"1234567", "89abcde"},
refPatterns: []string{"refs/heads/a", "refs/remotes/origin/b"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:1234567)%00%(ahead-behind:89abcde)",
"refs/heads/a",
"refs/remotes/origin/b",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := buildAheadBehindForEachRefArgs(s.bases, s.refPatterns)
assert.Equal(t, s.expected, result)
})
}
}
+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()
+99 -104
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,13 +70,21 @@ 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) {
branches := self.obtainBranches()
branches, tips := self.obtainBranches()
if self.UserConfig().Git.LocalBranchSortOrder == "recency" {
switch self.UserConfig().Git.LocalBranchSortOrder {
case "date":
if err := sortRefsWithEqualDatesByAncestry(
self.cmd, self.version, branches, (*models.Branch).FullRefName, tips,
); err != nil {
self.Log.Errorf("Failed to sort branches by ancestry: %v", err)
}
case "recency":
reflogBranches := self.obtainReflogBranches(reflogCommits)
// loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches
branchesWithRecency := make([]*models.Branch, 0)
@@ -127,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,
@@ -206,94 +256,6 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
return err
}
// Holds parsed values from a single %(ahead-behind:<base>) field.
type aheadBehind struct {
ahead, behind int
}
type branchAheadBehind struct {
refName string
aheadBehinds []aheadBehind
}
// Parses output produced by:
//
// git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries
func parseAheadBehindForEachRefOutput(
output string,
numBases int, // number of %(ahead-behind:...) tokens
) []branchAheadBehind {
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
result := make([]branchAheadBehind, 0, len(lines))
for _, line := range lines {
cols := strings.Split(line, "\x00")
if len(cols) != numBases+1 {
continue
}
refName := cols[0]
aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
return parseAheadBehindField(col)
})
entry := branchAheadBehind{
refName: refName,
aheadBehinds: aheadBehinds,
}
result = append(result, entry)
}
return result
}
func parseAheadBehindField(s string) (aheadBehind, bool) {
parts := strings.Fields(s)
if len(parts) != 2 {
return aheadBehind{}, false
}
ahead, err1 := strconv.Atoi(parts[0])
behind, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return aheadBehind{}, false
}
return aheadBehind{ahead: ahead, behind: behind}, true
}
// Picks the "closest" base by smallest ahead value (commits the branch
// has that the base doesn't = roughly "since fork point") and returns
// its behind value.
// Ties are broken by index order
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool {
return a.ahead < b.ahead
}).behind
}
// The output format is:
//
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
//
// with one ahead-behind field per base, in the same order as mainBranchRefs.
//
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
formatParts := make([]string, 0, 1+len(mainBranchRefs))
formatParts = append(formatParts, "%(refname)")
for _, ref := range mainBranchRefs {
formatParts = append(formatParts, "%(ahead-behind:"+ref+")")
}
format := strings.Join(formatParts, "%00")
return NewGitCmd("for-each-ref").
Arg("--format=" + format).
Arg("refs/heads").
ToArgv()
}
func (self *BranchLoader) getBehindBaseBranchValuesFast(
branches []*models.Branch,
mainBranchRefs []string,
@@ -302,7 +264,7 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast(
t := time.Now()
output, err := self.cmd.New(
buildAheadBehindForEachRefArgs(mainBranchRefs),
buildAheadBehindForEachRefArgs(mainBranchRefs, []string{"refs/heads"}),
).DontLog().RunWithOutput()
if err != nil {
return err
@@ -362,7 +324,9 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai
return split[0], nil
}
func (self *BranchLoader) obtainBranches() []*models.Branch {
// Returns the branches, along with the tip of each of them, keyed by full ref
// name
func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) {
output, err := self.getRawBranches()
if err != nil {
panic(err)
@@ -371,7 +335,8 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
trimmedOutput := strings.TrimSpace(output)
outputLines := strings.Split(trimmedOutput, "\n")
return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
tips := make(map[string]refTip, len(outputLines))
branches := lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
if line == "" {
return nil, false
}
@@ -385,8 +350,12 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
}
storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency"
return obtainBranch(split, storeCommitDateAsRecency), true
branch, tip := obtainBranch(split, storeCommitDateAsRecency)
tips[branch.FullRefName()] = tip
return branch, true
})
return branches, tips
}
func (self *BranchLoader) getRawBranches() (string, error) {
@@ -422,25 +391,28 @@ var branchFields = []string{
"upstream:short",
"upstream:track",
"push:track",
"push",
"subject",
"objectname",
"committerdate:unix",
}
// Obtain branch information from parsed line output of getRawBranches()
func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch {
func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch, refTip) {
headMarker := split[0]
fullName := split[1]
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 {
@@ -449,18 +421,22 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch
}
}
return &models.Branch{
branch := &models.Branch{
Name: name,
Recency: recency,
AheadForPull: aheadForPull,
BehindForPull: behindForPull,
AheadForPush: aheadForPush,
BehindForPush: behindForPush,
PushRemote: pushRemote,
PushBranch: pushBranch,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
CommitHash: commitHash,
}
return branch, refTip{hash: commitHash, committerDate: commitDate}
}
func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) {
@@ -481,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)
+105 -244
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",
@@ -120,245 +158,9 @@ func TestObtainBranch(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
branch := obtainBranch(s.input, s.storeCommitDateAsRecency)
branch, tip := obtainBranch(s.input, s.storeCommitDateAsRecency)
assert.EqualValues(t, s.expectedBranch, branch)
})
}
}
func TestParseAheadBehindForEachRefOutput(t *testing.T) {
type scenario struct {
testName string
input string
numBases int
expected []branchAheadBehind
}
scenarios := []scenario{
{
testName: "single branch single base",
input: "refs/heads/feat\x002 5\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}},
},
},
},
{
testName: "multiple branches multiple bases",
input: "refs/heads/feat\x002 5\x0010 1\n" +
"refs/heads/main\x000 0\x000 0\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
{ahead: 10, behind: 1},
},
},
{
refName: "refs/heads/main",
aheadBehinds: []aheadBehind{
{ahead: 0, behind: 0},
{ahead: 0, behind: 0},
},
},
},
},
{
testName: "empty ahead-behind field for unreachable base",
input: "refs/heads/feat\x00\x002 5\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
},
},
},
},
{
testName: "ref name containing slashes and dashes",
input: "refs/heads/feat/foo-bar\x001 2\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat/foo-bar",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
},
},
{
testName: "trailing newline and blank lines are ignored",
input: "refs/heads/feat\x001 2\n\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
},
},
{
testName: "line with wrong column count is skipped",
input: "refs/heads/good\x001 2\n" +
"refs/heads/bad\n" +
"refs/heads/also_good\x003 4\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/good",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
{
refName: "refs/heads/also_good",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}},
},
},
},
{
testName: "malformed ahead-behind field becomes invalid but line is kept",
input: "refs/heads/feat\x00not_a_number\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{},
},
},
},
{
testName: "empty input",
input: "",
numBases: 1,
expected: nil,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := parseAheadBehindForEachRefOutput(s.input, s.numBases)
assert.Equal(t, s.expected, result)
})
}
}
func TestSelectBehindForBranch(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
expected int
}
scenarios := []scenario{
{
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}},
expected: 7,
},
{
testName: "multi-base, clear winner by ahead",
aheadBehinds: []aheadBehind{
{ahead: 50, behind: 10}, // master
{ahead: 5, behind: 2}, // develop ← smallest ahead
},
expected: 2,
},
{
testName: "develop forked from master case (ancestor-of-each-other)",
// feat-x has 5 commits since fork from develop.
// develop is 50 commits ahead of master.
// ahead vs master = 5 + 50 = 55; behind vs master = 0
// ahead vs develop = 5; behind vs develop = 5
aheadBehinds: []aheadBehind{
{ahead: 55, behind: 0}, // master
{ahead: 5, behind: 5}, // develop ← smallest ahead
},
expected: 5,
},
{
testName: "tie on ahead - first base wins (config order)",
aheadBehinds: []aheadBehind{
{ahead: 5, behind: 10}, // first
{ahead: 5, behind: 99}, // second, same ahead
},
expected: 10,
},
{
testName: "first base invalid, second valid",
aheadBehinds: []aheadBehind{
{ahead: 3, behind: 8},
},
expected: 8,
},
{
testName: "all invalid - returns 0",
aheadBehinds: []aheadBehind{},
expected: 0,
},
{
testName: "empty - returns 0",
aheadBehinds: nil,
expected: 0,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := selectBehindForBranch(s.aheadBehinds)
assert.Equal(t, s.expected, result)
})
}
}
func TestBuildAheadBehindForEachRefArgs(t *testing.T) {
type scenario struct {
testName string
mainBranchRefs []string
expected []string
}
scenarios := []scenario{
{
testName: "single base",
mainBranchRefs: []string{"refs/heads/master"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)",
"refs/heads",
},
},
{
testName: "two bases",
mainBranchRefs: []string{"refs/heads/master", "refs/remotes/origin/develop"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)",
"refs/heads",
},
},
{
testName: "four bases",
mainBranchRefs: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/a)%00%(ahead-behind:refs/heads/b)%00%(ahead-behind:refs/heads/c)%00%(ahead-behind:refs/heads/d)",
"refs/heads",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := buildAheadBehindForEachRefArgs(s.mainBranchRefs)
assert.Equal(t, s.expected, result)
assert.Equal(t, refTip{hash: "123", committerDate: timeStamp}, tip)
})
}
}
@@ -491,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",
@@ -19,12 +19,12 @@ import (
// that opts back in is the foreground files refresh; see FileLoader.gitStatus.
const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS"
// forOtherRepo prepares a command that operates on a repo other than the one
// ForOtherRepo prepares a command that operates on a repo other than the one
// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE
// say where our repo is, and every command we run inherits them, so a command
// pointed at a different repo would be resolved against ours instead: `git -C
// <submodule> log` would silently log the superproject's commits.
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
func ForOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}
@@ -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").
+223
View File
@@ -0,0 +1,223 @@
package git_commands
import (
"slices"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
// The tip commit of a ref, as far as sorting refs by ancestry needs it. The
// date is the raw %(committerdate:unix) field, and is only ever compared for
// equality.
type refTip struct {
hash string
committerDate string
}
// Determining ancestry costs one %(ahead-behind:<tip>) field per tip, and git
// evaluates each of those for every tip we ask about, so the work grows with
// the square of the number of tips. Branches whose tips came out of one rebase
// are nowhere near this many, so leave the refs in the order git returned them
// once the tips add up to more than this.
const maxTipsForAncestrySorting = 100
// Sorts each group of refs that share a committer date so that a ref comes
// before the refs it is descended from. Refs that are not descended from one
// another keep the order they came in.
//
// git sorts refs with equal committer dates by name, and a stack of branches
// that gets rebased in one go ends up with the same committer date on all of
// its tips. Without this, such a stack appears in alphabetical order.
//
// refs must be sorted by committer date already, and tips must have an entry
// for each of them.
func sortRefsWithEqualDatesByAncestry[T any](
cmd oscommands.ICmdObjBuilder,
version *GitVersion,
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) error {
// %(ahead-behind:...) was added in git 2.41
if !version.IsAtLeast(2, 41, 0) {
return nil
}
groups := lo.Filter(groupRefsWithEqualDates(refs, fullRefName, tips),
func(group []T, _ int) bool {
return pointAtMoreThanOneCommit(group, fullRefName, tips)
})
if len(groups) == 0 {
return nil
}
// Ancestry is a property of the tip commits, so ask git about each of them
// once, however many refs point at it. A repository with several remotes
// has the same branches under each of them, and they all share a date.
tipHashes := []string{}
refNames := []string{}
seenTips := set.New[string]()
for _, ref := range lo.Flatten(groups) {
refName := fullRefName(ref)
if hash := tips[refName].hash; !seenTips.Includes(hash) {
seenTips.Add(hash)
tipHashes = append(tipHashes, hash)
refNames = append(refNames, refName)
}
}
if len(tipHashes) > maxTipsForAncestrySorting {
return nil
}
containedTips, err := loadContainedTips(cmd, tipHashes, refNames)
if err != nil {
return err
}
for _, group := range groups {
sortGroupByAncestry(group, fullRefName, tips, containedTips)
}
return nil
}
// Refs that all point at the same commit have no order to be put in, so
// there is nothing to ask git about them
func pointAtMoreThanOneCommit[T any](
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) bool {
firstHash := tips[fullRefName(refs[0])].hash
return lo.SomeBy(refs, func(ref T) bool {
return tips[fullRefName(ref)].hash != firstHash
})
}
// Returns the runs of consecutive refs that share a committer date, for runs of
// more than one ref. The returned slices share their backing array with refs,
// so sorting a run sorts that part of refs.
func groupRefsWithEqualDates[T any](
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) [][]T {
committerDate := func(ref T) string {
return tips[fullRefName(ref)].committerDate
}
groups := [][]T{}
start := 0
for i := 1; i <= len(refs); i++ {
if i < len(refs) && committerDate(refs[i]) == committerDate(refs[start]) {
continue
}
if i-start > 1 {
groups = append(groups, refs[start:i])
}
start = i
}
return groups
}
// For each of the given tips, which of the tips its history contains. Keyed by
// tip hash, with an entry for every tip passed in. refNames names, for each
// tip, a ref that points at it; git reports the values by ref name.
func loadContainedTips(
cmd oscommands.ICmdObjBuilder,
tipHashes []string,
refNames []string,
) (map[string]*set.Set[string], error) {
output, err := cmd.New(
buildAheadBehindForEachRefArgs(tipHashes, refNames),
).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
containedTips := make(map[string]*set.Set[string], len(tipHashes))
for _, hash := range tipHashes {
containedTips[hash] = set.New[string]()
}
tipByRefName := make(map[string]string, len(refNames))
for i, refName := range refNames {
tipByRefName[refName] = tipHashes[i]
}
for _, entry := range parseAheadBehindForEachRefOutput(output, len(tipHashes)) {
hash, ok := tipByRefName[entry.refName]
if !ok {
continue
}
contained := containedTips[hash]
for i, ab := range entry.aheadBehinds {
// The tip's history contains the other tip if it has commits the
// other one doesn't have, and the other one has none it doesn't
// have.
if ab.valid && ab.ahead > 0 && ab.behind == 0 {
contained.Add(tipHashes[i])
}
}
}
return containedTips, nil
}
func sortGroupByAncestry[T any](
group []T,
fullRefName func(T) string,
tips map[string]refTip,
containedTips map[string]*set.Set[string],
) {
hashes := lo.Map(group, func(ref T, _ int) string {
return tips[fullRefName(ref)].hash
})
contained := lo.Map(hashes, func(hash string, _ int) *set.Set[string] {
if contained, ok := containedTips[hash]; ok {
return contained
}
return set.New[string]()
})
isDescendedFrom := func(descendant int, ancestor int) bool {
return contained[descendant].Includes(hashes[ancestor])
}
// Indices into group, holding the refs we haven't placed yet
remaining := lo.Range(len(group))
// A ref can only go in once every ref that is descended from it is in
canBePlaced := func(i int) bool {
return !lo.SomeBy(remaining, func(j int) bool {
return isDescendedFrom(j, i)
})
}
sorted := make([]T, 0, len(group))
lastPlaced := -1
for len(remaining) > 0 {
next := -1
if lastPlaced != -1 {
// Walk from the ref we placed last down to the refs it is based on,
// so that the branches of a stack come out as one run even when an
// unrelated branch sorts into the middle of them by name
next = slices.IndexFunc(remaining, func(i int) bool {
return isDescendedFrom(lastPlaced, i) && canBePlaced(i)
})
}
if next == -1 {
next = slices.IndexFunc(remaining, canBePlaced)
}
if next == -1 {
// Commits can't descend from each other in a circle; only take the
// first ref so that we don't spin if the values ever say otherwise.
next = 0
}
lastPlaced = remaining[next]
sorted = append(sorted, group[lastPlaced])
remaining = slices.Delete(remaining, next, next+1)
}
copy(group, sorted)
}
@@ -0,0 +1,331 @@
package git_commands
import (
"errors"
"fmt"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
// A branch as the tests below describe it: a name, the hash of its tip, and the
// committer date of its tip
type testRef struct {
name string
hash string
date string
}
func buildTestRefs(refs []testRef) ([]*models.Branch, map[string]refTip) {
branches := lo.Map(refs, func(ref testRef, _ int) *models.Branch {
return &models.Branch{Name: ref.name, CommitHash: ref.hash}
})
tips := make(map[string]refTip, len(refs))
for _, ref := range refs {
tips["refs/heads/"+ref.name] = refTip{hash: ref.hash, committerDate: ref.date}
}
return branches, tips
}
func branchNames(branches []*models.Branch) []string {
return lo.Map(branches, func(branch *models.Branch, _ int) string {
return branch.Name
})
}
func TestGroupRefsWithEqualDates(t *testing.T) {
scenarios := []struct {
testName string
refs []testRef
expected [][]string
}{
{
testName: "no refs",
refs: []testRef{},
expected: [][]string{},
},
{
testName: "all dates distinct",
refs: []testRef{
{name: "a", hash: "1", date: "300"},
{name: "b", hash: "2", date: "200"},
{name: "c", hash: "3", date: "100"},
},
expected: [][]string{},
},
{
testName: "all dates equal",
refs: []testRef{
{name: "a", hash: "1", date: "100"},
{name: "b", hash: "2", date: "100"},
{name: "c", hash: "3", date: "100"},
},
expected: [][]string{{"a", "b", "c"}},
},
{
testName: "two groups, with a lone ref between and after them",
refs: []testRef{
{name: "a", hash: "1", date: "300"},
{name: "b", hash: "2", date: "300"},
{name: "c", hash: "3", date: "200"},
{name: "d", hash: "4", date: "100"},
{name: "e", hash: "5", date: "100"},
{name: "f", hash: "6", date: "50"},
},
expected: [][]string{{"a", "b"}, {"d", "e"}},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
branches, tips := buildTestRefs(s.refs)
groups := groupRefsWithEqualDates(branches, (*models.Branch).FullRefName, tips)
assert.Equal(t, s.expected, lo.Map(groups,
func(group []*models.Branch, _ int) []string {
return branchNames(group)
}))
})
}
}
func TestSortRefsWithEqualDatesByAncestry(t *testing.T) {
// A stack of three branches, all committed within the same second, as git
// returns them: sorted by name. bottom is the base of middle, which is the
// base of top.
stack := []testRef{
{name: "middle", hash: "m", date: "100"},
{name: "top", hash: "t", date: "100"},
{name: "bottom", hash: "b", date: "100"},
}
// One %(ahead-behind:<hash>) field per tip, in the order the tips appear in
// the branch list
stackOutput := "refs/heads/middle\x000 0\x000 1\x001 0\n" +
"refs/heads/top\x001 0\x000 0\x002 0\n" +
"refs/heads/bottom\x000 1\x000 2\x000 0\n"
stackArgs := []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:b)",
"refs/heads/middle", "refs/heads/top", "refs/heads/bottom",
}
scenarios := []struct {
testName string
refs []testRef
gitVersion *GitVersion
expectedArgs []string
output string
outputErr error
expectedOrder []string
expectedErr string
}{
{
testName: "a stack of branches is sorted from the top down",
refs: stack,
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: stackArgs,
output: stackOutput,
expectedOrder: []string{"top", "middle", "bottom"},
},
{
testName: "git too old for %(ahead-behind:...), so nothing to do",
refs: stack,
gitVersion: &GitVersion{2, 40, 0, ""},
expectedOrder: []string{"middle", "top", "bottom"},
},
{
testName: "no two refs share a date, so nothing to do",
refs: []testRef{
{name: "middle", hash: "m", date: "300"},
{name: "top", hash: "t", date: "200"},
{name: "bottom", hash: "b", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedOrder: []string{"middle", "top", "bottom"},
},
{
testName: "the command fails",
refs: stack,
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: stackArgs,
outputErr: errors.New("fatal: failed to find 'm'"),
expectedOrder: []string{"middle", "top", "bottom"},
expectedErr: "fatal: failed to find 'm'",
},
{
testName: "a branch unrelated to the stack is placed by name",
refs: []testRef{
{name: "middle", hash: "m", date: "100"},
{name: "other", hash: "o", date: "100"},
{name: "top", hash: "t", date: "100"},
{name: "bottom", hash: "b", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:o)%00%(ahead-behind:t)%00%(ahead-behind:b)",
"refs/heads/middle", "refs/heads/other", "refs/heads/top", "refs/heads/bottom",
},
output: "refs/heads/middle\x000 0\x002 1\x000 1\x001 0\n" +
"refs/heads/other\x001 2\x000 0\x001 3\x001 1\n" +
"refs/heads/top\x001 0\x003 1\x000 0\x002 0\n" +
"refs/heads/bottom\x000 1\x001 1\x000 2\x000 0\n",
expectedOrder: []string{"other", "top", "middle", "bottom"},
},
{
testName: "a stack stays together when a branch sorts into the middle of it",
refs: []testRef{
{name: "add-tests", hash: "t", date: "100"},
{name: "cleanup", hash: "c", date: "100"},
{name: "fix-parser", hash: "p", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:t)%00%(ahead-behind:c)%00%(ahead-behind:p)",
"refs/heads/add-tests", "refs/heads/cleanup", "refs/heads/fix-parser",
},
// add-tests is based on fix-parser, and cleanup is on a line of its
// own, but its name sorts between the two
output: "refs/heads/add-tests\x000 0\x002 1\x001 0\n" +
"refs/heads/cleanup\x001 2\x000 0\x001 1\n" +
"refs/heads/fix-parser\x000 1\x001 1\x000 0\n",
expectedOrder: []string{"add-tests", "fix-parser", "cleanup"},
},
{
testName: "each group is sorted on its own",
refs: []testRef{
{name: "middle", hash: "m", date: "200"},
{name: "top", hash: "t", date: "200"},
{name: "lone", hash: "l", date: "150"},
{name: "base", hash: "a", date: "100"},
{name: "derived", hash: "d", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:a)%00%(ahead-behind:d)",
"refs/heads/middle", "refs/heads/top", "refs/heads/base", "refs/heads/derived",
},
output: "refs/heads/middle\x000 0\x000 1\x002 0\x001 0\n" +
"refs/heads/top\x001 0\x000 0\x003 0\x002 0\n" +
"refs/heads/base\x000 2\x000 3\x000 0\x000 1\n" +
"refs/heads/derived\x000 1\x000 2\x001 0\x000 0\n",
expectedOrder: []string{"top", "middle", "lone", "derived", "base"},
},
{
testName: "a group whose branches are all on one commit is left alone",
refs: []testRef{
{name: "a", hash: "x", date: "100"},
{name: "b", hash: "x", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedOrder: []string{"a", "b"},
},
{
testName: "branches on the same commit are asked about once",
refs: []testRef{
{name: "mirror-1", hash: "s", date: "100"},
{name: "mirror-2", hash: "s", date: "100"},
{name: "stack-bottom", hash: "u", date: "100"},
{name: "stack-top", hash: "t", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:s)%00%(ahead-behind:u)%00%(ahead-behind:t)",
"refs/heads/mirror-1", "refs/heads/stack-bottom", "refs/heads/stack-top",
},
output: "refs/heads/mirror-1\x000 0\x001 1\x001 2\n" +
"refs/heads/stack-bottom\x001 1\x000 0\x000 1\n" +
"refs/heads/stack-top\x002 1\x001 0\x000 0\n",
expectedOrder: []string{"mirror-1", "mirror-2", "stack-top", "stack-bottom"},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
runner := oscommands.NewFakeRunner(t)
if s.expectedArgs != nil {
runner.ExpectGitArgs(s.expectedArgs, s.output, s.outputErr)
}
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: s.gitVersion})
branches, tips := buildTestRefs(s.refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
if s.expectedErr == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, s.expectedErr)
}
assert.Equal(t, s.expectedOrder, branchNames(branches))
runner.CheckForMissingCalls()
})
}
}
func TestSortRefsWithEqualDatesByAncestry_TooManyTips(t *testing.T) {
refs := lo.Map(lo.Range(maxTipsForAncestrySorting+1), func(i int, _ int) testRef {
return testRef{
name: fmt.Sprintf("branch-%03d", i),
hash: fmt.Sprintf("hash-%03d", i),
date: "100",
}
})
// The runner fails the test if the command runs at all
runner := oscommands.NewFakeRunner(t)
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
branches, tips := buildTestRefs(refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
assert.NoError(t, err)
assert.Equal(t, lo.Map(refs, func(ref testRef, _ int) string { return ref.name }),
branchNames(branches))
runner.CheckForMissingCalls()
}
// A repository with many remotes has the same branches under each of them, so
// a group can hold far more refs than the commits they point at
func TestSortRefsWithEqualDatesByAncestry_ManyRefsOnTwoTips(t *testing.T) {
refs := lo.Map(lo.Range(4*maxTipsForAncestrySorting), func(i int, _ int) testRef {
return testRef{
name: fmt.Sprintf("branch-%03d", i),
hash: lo.Ternary(i%2 == 0, "bottom", "top"),
date: "100",
}
})
runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:bottom)%00%(ahead-behind:top)",
"refs/heads/branch-000", "refs/heads/branch-001",
},
"refs/heads/branch-000\x000 0\x000 1\n"+
"refs/heads/branch-001\x001 0\x000 0\n", nil)
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
branches, tips := buildTestRefs(refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
assert.NoError(t, err)
// the branches on the top commit first, each half still ordered by name
expected := append(
lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
return ref.name, ref.hash == "top"
}),
lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
return ref.name, ref.hash == "bottom"
})...)
assert.Equal(t, expected, branchNames(branches))
runner.CheckForMissingCalls()
}
+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()
+52 -56
View File
@@ -5,53 +5,25 @@ import (
"maps"
"slices"
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type RemoteLoader struct {
*common.Common
cmd oscommands.ICmdObjBuilder
*GitCommon
}
func NewRemoteLoader(
common *common.Common,
cmd oscommands.ICmdObjBuilder,
) *RemoteLoader {
return &RemoteLoader{
Common: common,
cmd: cmd,
}
func NewRemoteLoader(gitCommon *GitCommon) *RemoteLoader {
return &RemoteLoader{GitCommon: gitCommon}
}
func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
wg := sync.WaitGroup{}
wg.Add(1)
var remoteBranchesByRemoteName map[string][]*models.RemoteBranch
var remoteBranchesErr error
go utils.Safe(func() {
defer wg.Done()
remoteBranchesByRemoteName, remoteBranchesErr = self.getRemoteBranchesByRemoteName()
})
// GetRemotes returns the repo's remotes, without their branches; those are
// loaded separately with GetRemoteBranchesByRemoteName, which takes a lot longer
// in a repo with many remote branches.
func (self *RemoteLoader) GetRemotes() []*models.Remote {
remotes := self.getRemotesFromConfig()
wg.Wait()
if remoteBranchesErr != nil {
return nil, remoteBranchesErr
}
for _, remote := range remotes {
remote.Branches = remoteBranchesByRemoteName[remote.Name]
}
// now lets sort our remotes by name alphabetically
slices.SortFunc(remotes, func(a, b *models.Remote) int {
// we want origin at the top because we'll be most likely to want it
@@ -64,7 +36,7 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return remotes, nil
return remotes
}
func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
@@ -111,29 +83,47 @@ func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
return slices.Collect(maps.Values(remotesByName))
}
func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
remoteBranchesByRemoteName := make(map[string][]*models.RemoteBranch)
// GetRemoteBranchesByRemoteName returns all remote branches, keyed by the name
// of the remote they belong to.
func (self *RemoteLoader) GetRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
remoteBranches, err := self.getRemoteBranches()
if err != nil {
return nil, err
}
var sortOrder string
switch strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) {
case "alphabetical":
sortOrder = "refname"
case "date":
return lo.GroupBy(remoteBranches, func(branch *models.RemoteBranch) string {
return branch.RemoteName
}), nil
}
// Returns all remote branches, sorted the way the config asks for
func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) {
sortByDate := strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) == "date"
sortOrder := "refname"
if sortByDate {
sortOrder = "-committerdate"
default:
sortOrder = "refname"
}
// Asking for the tip of a branch makes git read its commit, so only do it
// when we are going to sort by ancestry below
format := "%(refname)"
if sortByDate {
format += "%00%(objectname)%00%(committerdate:unix)"
}
cmdArgs := NewGitCmd("for-each-ref").
Arg(fmt.Sprintf("--sort=%s", sortOrder)).
Arg("--format=%(refname)").
Arg(fmt.Sprintf("--format=%s", format)).
Arg("refs/remotes").
ToArgv()
remoteBranches := []*models.RemoteBranch{}
tips := map[string]refTip{}
err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
line = strings.TrimSpace(line)
fields := strings.Split(strings.TrimSpace(line), "\x00")
refName := fields[0]
split := strings.SplitN(line, "/", 4)
split := strings.SplitN(refName, "/", 4)
if len(split) != 4 {
return false, nil
}
@@ -144,21 +134,27 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.
return false, nil
}
_, ok := remoteBranchesByRemoteName[remoteName]
if !ok {
remoteBranchesByRemoteName[remoteName] = []*models.RemoteBranch{}
}
remoteBranchesByRemoteName[remoteName] = append(remoteBranchesByRemoteName[remoteName],
remoteBranches = append(remoteBranches,
&models.RemoteBranch{
Name: name,
RemoteName: remoteName,
})
if len(fields) == 3 {
tips[refName] = refTip{hash: fields[1], committerDate: fields[2]}
}
return false, nil
})
if err != nil {
return nil, err
}
return remoteBranchesByRemoteName, nil
if sortByDate {
if err := sortRefsWithEqualDatesByAncestry(
self.cmd, self.version, remoteBranches, (*models.RemoteBranch).FullRefName, tips,
); err != nil {
self.Log.Errorf("Failed to sort remote branches by ancestry: %v", err)
}
}
return remoteBranches, nil
}
@@ -6,7 +6,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -95,8 +94,7 @@ func TestGetRemotesFromConfig(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
loader := &RemoteLoader{
Common: common.NewDummyCommon(),
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
GitCommon: buildGitCommon(commonDeps{runner: scenario.runner}),
}
// map iteration order is non-deterministic, so compare unordered
+2 -2
View File
@@ -271,13 +271,13 @@ func callGitRevParseWithDir(
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// Asks git about a repo that isn't the one we have open; see forOtherRepo.
// Asks git about a repo that isn't the one we have open; see ForOtherRepo.
func callGitRevParseInOtherRepo(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
return runGitRevParse(ForOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
}
func newGitRevParseCmd(
+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",
+5 -5
View File
@@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false").
ToArgv()
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
summary, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err
}
@@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
// caller then stages the submodule to record the resolution.
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
@@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
@@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked").
ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
@@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error {
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil {
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
ForOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
}
return cmdObj.Run()
}
+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()
+1 -1
View File
@@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
func (self *WorktreeCommands) Detach(worktreePath string) error {
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
@@ -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
+5
View File
@@ -16,6 +16,11 @@ type Hunk struct {
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the header line ('2' and '3' in the above example),
// kept so that we can check the parsed body against them (see
// Patch.IsWellFormed). Only set by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
+26 -11
View File
@@ -7,7 +7,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
// Captures, in order: the old start, the old length (omitted by git when it is
// 1), the new start, the new length (likewise), and the trailing context.
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, newStart, headerContext := headerInfo(line)
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (int, int, string) {
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
return oldStart, newStart, headerContext
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a length capture of a hunk header, which git omits when
// it is 1 (e.g. "@@ -0,0 +1 @@").
func declaredLength(match string) int {
if match == "" {
return 1
}
return utils.MustConvertToInt(match)
}
func newHunkLine(line string) *PatchLine {
+69
View File
@@ -79,6 +79,44 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
}
// IsWellFormed reports whether every hunk's body matches the lengths declared in
// its header. A faithful unified diff always satisfies this; a rendering that
// restructured the diff body does not — a diff renderer that puts line numbers in
// a gutter, say, shifts the +/- marker off the start of each line, so every body
// line reads as context and the computed lengths no longer match the header. That
// makes this the test for whether a rendered diff can be parsed as a unified diff
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
// by Parse, which is where the declared lengths come from.
func (self *Patch) IsWellFormed() bool {
return self.isWellFormed(false)
}
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
// so every hunk but the last has to match its header exactly, as before, while the last
// one only has to fit within what its header declares.
//
// The check exists to tell a faithful rendering from a restructured one, and it still
// does that. A rendering that moves the +/- marker off the start of the line makes us
// read a change as context, and a context line counts towards both lengths, so such a
// hunk comes out longer than its header declares rather than shorter.
func (self *Patch) IsWellFormedSoFar() bool {
return self.isWellFormed(true)
}
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
for i, hunk := range self.hunks {
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
return hunk.oldLength() <= hunk.declaredOldLength &&
hunk.newLength() <= hunk.declaredNewLength
}
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
return false
}
}
return true
}
func (self *Patch) ContainsChanges() bool {
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
return hunk.containsChanges()
@@ -114,6 +152,37 @@ func (self *Patch) LineNumberOfLine(idx int) int {
return hunk.newStart + offset
}
// Takes a line index in the patch and returns the line number in the old file.
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
// the line's position in the old file (additions get the position they sit at).
// If the line is a header line, returns 1.
// If the line is a hunk header line, returns the first old-file line number in that hunk.
// If the line is out of range below, returns the last old-file line number in the last hunk.
func (self *Patch) OldLineNumberOfLine(idx int) int {
if idx < len(self.header) || len(self.hunks) == 0 {
return 1
}
hunkIdx := self.HunkContainingLine(idx)
// cursor out of range, return last file line number
if hunkIdx == -1 {
lastHunk := self.hunks[len(self.hunks)-1]
return lastHunk.oldStart + lastHunk.oldLength() - 1
}
hunk := self.hunks[hunkIdx]
hunkStartIdx := self.HunkStartIdx(hunkIdx)
idxInHunk := idx - hunkStartIdx
if idxInHunk == 0 {
return hunk.oldStart
}
lines := hunk.bodyLines[:idxInHunk-1]
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
return hunk.oldStart + offset
}
// Returns hunk index containing the line at the given patch line index
func (self *Patch) HunkContainingLine(idx int) int {
for hunkIdx, hunk := range self.hunks {
+156
View File
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
lemon
`
// Two deletions with no line between them: they share a new-file line number
// (both sit at the same new-file position), so only their old-file line numbers
// tell them apart.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
`
const newFile = `diff --git a/newfile b/newfile
new file mode 100644
index 0000000..4e680cc
@@ -682,6 +696,148 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestIsWellFormed(t *testing.T) {
// The body of a diff as rendered with the +/- markers moved out of the text
// and into a gutter: every body line now reads as context, so the lengths no
// longer match the header.
const gutterMangled = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"renameWithModificationDiff", renameWithModificationDiff, true},
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
{"twoHunks", twoHunks, true},
{"consecutiveDeletions", consecutiveDeletions, true},
{"newFile", newFile, true},
{"deletedFile", deletedFile, true},
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
{"exampleHunk", exampleHunk, true},
{"gutterMangled", gutterMangled, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
})
}
}
func TestIsWellFormedSoFar(t *testing.T) {
// A diff read only as far as the middle of its second hunk.
const cutShort = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
+orange
...
...
...
@@ -8,6 +8,8 @@ grape
...
...
`
// The same diff cut short in its first hunk, so that the second is missing
// entirely rather than short.
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
`
// A rendering with the +/- markers moved into a gutter, cut short: reading the
// changes as context makes the hunk longer than its header declares, not shorter,
// so it doesn't pass for a diff we only have the beginning of.
const gutterMangledAndCutShort = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
melon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"twoHunks", twoHunks, true},
{"cutShort", cutShort, true},
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
})
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
patchStr string
indexes []int
expecteds []int
}
scenarios := []scenario{
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
},
{
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
{
testName: "renameWithModificationDiff",
patchStr: renameWithModificationDiff,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, idx := range s.indexes {
patch := Parse(s.patchStr)
result := patch.OldLineNumberOfLine(idx)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
+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
}
+36 -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
@@ -660,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
type KeybindingMainConfig struct {
PrevHunk Keybinding `yaml:"prevHunk"`
NextHunk Keybinding `yaml:"nextHunk"`
PrevFile Keybinding `yaml:"prevFile"`
NextFile Keybinding `yaml:"nextFile"`
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
PickBothHunks Keybinding `yaml:"pickBothHunks"`
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
@@ -881,19 +895,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{"default"},
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,
@@ -1170,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
Main: KeybindingMainConfig{
PrevHunk: Keybinding{"<left>", "h"},
NextHunk: Keybinding{"<right>", "l"},
PrevFile: Keybinding{"N"},
NextFile: Keybinding{"n"},
ToggleSelectHunk: Keybinding{"a"},
PickBothHunks: Keybinding{"b"},
EditSelectHunk: Keybinding{"E"},
+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)
}
+1
View File
@@ -41,6 +41,7 @@ func SimpleEditor(v *View, key Key) bool {
key.Equals(NewKeyStrMod("d", ModAlt)):
v.TextArea.ForwardDeleteWord()
case key.Equals(NewKeyName(KeyBackspace)),
key.Equals(NewKey(KeyBackspace, "", ModShift)),
key.Equals(NewKeyStrMod("h", ModCtrl)):
v.TextArea.BackSpaceChar()
case key.Equals(NewKeyStrMod("d", ModCtrl)),
+97 -20
View File
@@ -20,6 +20,25 @@ type escapeInterpreter struct {
instruction instruction
hyperlink strings.Builder
// the digits of the OSC number seen so far, while we don't yet know which
// OSC this is
oscNumber strings.Builder
// the payload of an OSC 1717 sequence, in which a diff renderer states
// which line of which file it is about to render; accumulated like
// hyperlink, and attached to the cells that follow it
metadata strings.Builder
// whether the payload currently in metadata has reached a cell, so that one
// that never does can be recognized and kept as an orphan
metadataConsumed bool
// OSC 1717 payloads that no cell took, because the next record followed
// with nothing rendered in between. A renderer emits records back to back
// wherever two diff lines share a rendered line — the deletion and the
// addition of a modification collapsed into one column, or a banner
// announcing a file and its first hunk at once. The write loop gives these
// cells of their own, so that a line keeps every record it was given rather
// than only the last.
orphanedMetadata []string
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
// rows rather than emitting LFs for them. To convert those into row
// advances the view can act on, we track where in the pseudo-terminal
@@ -82,9 +101,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -427,27 +446,42 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
}
return true, nil
case stateOSC:
if characterEquals(ch, '8') {
ei.state = stateOSCWaitForParams
ei.hyperlink.Reset()
// Accumulate the OSC number until the ';' that terminates it, then
// dispatch on the whole number rather than on a single digit.
switch {
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
ei.oscNumber.WriteByte(ch[0])
return true, nil
case characterEquals(ch, ';'):
switch ei.oscNumber.String() {
case "8":
ei.hyperlink.Reset()
ei.state = stateOSCParams
case "1717":
ei.orphanUnconsumedMetadata()
ei.state = stateOSCMetadata
default:
ei.state = stateOSCSkipUnknown
}
ei.oscNumber.Reset()
return true, nil
default:
// Not an OSC we understand — it has no number, or a character
// follows the number where the ';' should be. Rather than
// erroring, which would reset state mid-OSC and leak the rest of
// the sequence into the view as literal text, skip to its
// terminator, which this character may already be.
ei.oscNumber.Reset()
switch {
case characterEquals(ch, 0x07):
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.state = stateOSCEndEscape
default:
ei.state = stateOSCSkipUnknown
}
return true, nil
}
ei.state = stateOSCSkipUnknown
return true, nil
case stateOSCWaitForParams:
if !characterEquals(ch, ';') {
// Malformed OSC 8 (expected ';' after '8'). Rather than
// erroring — which would reset state mid-OSC and cause the
// rest of the sequence to leak as literal text — treat the
// whole OSC as one we don't understand and skip to its
// terminator.
ei.state = stateOSCSkipUnknown
return true, nil
}
ei.state = stateOSCParams
return true, nil
case stateOSCParams:
if characterEquals(ch, ';') {
ei.state = stateOSCHyperlink
@@ -463,6 +497,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.hyperlink.Write(ch)
}
return true, nil
case stateOSCMetadata:
switch {
case characterEquals(ch, 0x07):
ei.dropMetadataIfHandshake()
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.dropMetadataIfHandshake()
ei.state = stateOSCEndEscape
default:
ei.metadata.Write(ch)
}
return true, nil
case stateOSCEndEscape:
ei.state = stateNone
return true, nil
@@ -478,6 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
return false, nil
}
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
// record, keeping the payload it held as an orphan if no cell took it (see
// orphanedMetadata).
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
}
ei.metadata.Reset()
ei.metadataConsumed = false
}
// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and
// clears the list.
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
result := ei.orphanedMetadata
ei.orphanedMetadata = nil
return result
}
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that
// carries nothing beyond the version. A diff renderer emits such a record ahead
// of everything else to announce that it speaks the protocol, so that a host can
// find that out by asking rather than by inspecting a rendering. It says nothing
// about any line, so it must not attach to the line that follows it; a per-line
// record always has fields, and is kept.
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
if !strings.Contains(ei.metadata.String(), ";") {
ei.metadata.Reset()
}
}
func (ei *escapeInterpreter) outputCSI() error {
n := len(ei.csiParam)
for i := 0; i < n; {
+1
View File
@@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
"\x1b]8bogus\x07", // OSC 8 missing ';'
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
}
+50 -2
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()
}
@@ -419,7 +438,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.ClearViewLines()
v.RewrapContent()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -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
}
@@ -1534,7 +1576,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.ClearViewLines()
v.RewrapContent()
}
}
g.maxX, g.maxY = maxX, maxY
@@ -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
}
+378 -29
View File
@@ -5,6 +5,7 @@
package gocui
import (
"bytes"
"fmt"
"io"
"slices"
@@ -138,6 +139,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
@@ -157,6 +164,12 @@ type View struct {
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -245,23 +258,138 @@ type pos struct {
x, y int
}
// call this in the event of a view resize, or if you want to render new content
// without the chance of old content still appearing, or if you want to remove
// a line from the existing content
// call this if you want to render new content without the chance of old content
// still appearing, or if you want to remove a line from the existing content. For
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
func (v *View) clearViewLines() {
v.tainted = true
v.viewLines = nil
v.clearHover()
}
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
// the segments each line is wrapped into, so wrapping the content at another
// width leaves every one of them pointing at a different line.
//
// Call it on the UI thread whenever the view's size changes; a task goroutine may
// be writing the content concurrently, and all of this is state writeMutex
// protects.
func (v *View) RewrapContent() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
origin := v.contentPosOf(v.oy)
cursor := v.contentPosOf(v.oy + v.cy)
anchor := v.contentPosOf(v.rangeSelectStartY)
cursorRow := v.cy
v.clearViewLines()
v.refreshViewLinesIfNeeded()
if !origin.ok {
return
}
cursorLine, cursorOk := v.viewLineOf(cursor)
if anchorLine, ok := v.viewLineOf(anchor); ok {
v.rangeSelectStartY = anchorLine
if cursorOk {
// A range covers lines of content, not the wrapped segments those
// lines are drawn as, so its ends go back on the outermost segments
// of their lines: a line that was covered whole stays covered whole.
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
}
}
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
// move under the user; with no cursor on screen the view keeps its own place
// in the content instead.
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
v.SetOriginY(cursorLine - cursorRow)
} else if originLine, ok := v.viewLineOf(origin); ok {
v.SetOriginY(originLine)
}
if cursorOk {
v.cy = cursorLine - v.oy
}
}
// contentPos is a position in a view's content in terms that survive the content
// being wrapped again: which line of it, and which of that line's segments.
type contentPos struct {
line, segment int
ok bool
}
// contentPosOf returns where the given view line sits in the content. Only call
// this with a lock on writeMutex, and with the view lines up to date.
func (v *View) contentPosOf(viewLine int) contentPos {
if viewLine < 0 || viewLine >= len(v.viewLines) {
return contentPos{}
}
return contentPos{
line: v.viewLines[viewLine].linesY,
segment: v.viewLines[viewLine].linesX,
ok: true,
}
}
// viewLineOf returns the view line drawing the given position in the content,
// on the nearest segment its line still has. Only call this with a lock on
// writeMutex, and with the view lines up to date.
func (v *View) viewLineOf(pos contentPos) (int, bool) {
first, last, ok := v.segmentSpanOf(pos)
if !ok {
return 0, false
}
return min(first+pos.segment, last), true
}
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
// outermost segment of its line, so that the range covers that line whole. other
// is the range's other end, which says which way is outward. Both ends have to be
// positions whose lines are drawn, which viewLineOf answers.
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
first, last, _ := v.segmentSpanOf(pos)
if pos.line <= other.line {
return first
}
return last
}
// segmentSpanOf returns the first and last view line drawing the given position's
// line of the content. ok is false when the position was never taken, or its line
// isn't drawn at all.
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
if !pos.ok {
return 0, 0, false
}
return v.viewLineSpanOfBufferLine(pos.line)
}
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
// buffer line, i.e. the first and last segment it is wrapped into. Both are the
// same view line when the line doesn't wrap. ok is false when the line isn't drawn
// at all. Only call this with a lock on writeMutex, and with the view lines up to
// date.
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
first, last := -1, -1
for i, vline := range v.viewLines {
if vline.linesY == bufferLine {
if first == -1 {
first = i
}
last = i
} else if first != -1 {
break
}
}
return first, last, first != -1
}
type searcher struct {
@@ -499,6 +627,12 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
v.rangeSelectStartY = rangeSelectStartY
}
// RangeSelectStartY returns the view line the range selection is anchored on,
// or -1 when there is no range.
func (v *View) RangeSelectStartY() int {
return v.rangeSelectStartY
}
func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
@@ -572,6 +706,23 @@ type lineType struct {
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
// asWritten is the text of the line as its writer wrote it, escape sequences
// left out, kept from the first character on that the cells spell differently:
// a tab, which the cells hold as the spaces it fills, or a carriage return,
// which they hold as the overwrite it caused. nil while the cells spell the
// line as it was written, as they do for most lines.
asWritten []byte
}
// textAsWritten returns the line's text as its writer wrote it, escape sequences
// left out. A reader that parses a view's content rather than showing it wants
// this form; the cells' text is for showing.
func (l *lineType) textAsWritten() string {
if l.asWritten != nil {
return string(l.asWritten)
}
return l.cells.String()
}
// trailingFillAttributes describes the fg/bg colors that draw() should
@@ -588,6 +739,9 @@ type cell struct {
width int // number of terminal cells occupied by chr (always 1 or 2)
bgColor, fgColor Attribute
hyperlink string
// the OSC 1717 payload in effect when the cell was written, i.e. what the
// diff renderer said about the diff line this cell is part of
metadata string
}
type cells []cell
@@ -631,7 +785,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
@@ -721,13 +875,9 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
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
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
fgColor = applySelTextColor(fgColor, v.SelTextColor)
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
} else {
@@ -834,8 +984,7 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.buf.wx = x
v.buf.wy = y
v.buf.seekWrite(x, y)
// Changing the write position makes a pending newline obsolete
v.buf.pendingNewline = false
@@ -919,6 +1068,35 @@ func (b *viewBuffer) writeCells(cells []cell) {
b.wx += len(cells)
}
// seekWrite moves the write cursor to (x, y). Writing there starts the line over,
// so whatever it kept of its text as written is dropped; the text a line keeps is
// the text written to it from its start. A carriage return continues a line
// instead, and moves the cursor without this (see write).
func (b *viewBuffer) seekWrite(x, y int) {
b.wx = x
b.wy = y
if y < len(b.lines) {
b.lines[y].asWritten = nil
}
}
// startAsWritten begins keeping the current line's text as written (see
// lineType.asWritten), at the first character the cells won't spell the same way.
// Up to here they spell it exactly, so their text is what was written so far.
func (b *viewBuffer) startAsWritten() {
if line := &b.lines[b.wy]; line.asWritten == nil {
line.asWritten = append([]byte{}, line.cells.String()...)
}
}
// noteAsWritten records text the writer wrote to the current line, once the line
// keeps its text as written at all.
func (b *viewBuffer) noteAsWritten(text []byte) {
if line := &b.lines[b.wy]; line.asWritten != nil {
line.asWritten = append(line.asWritten, text...)
}
}
// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
@@ -962,14 +1140,30 @@ func (b *viewBuffer) write(v *View, p []byte) {
finishLine := func() {
b.autoRenderHyperlinksInCurrentLine(v)
// A record that reached the line's end without covering a cell still
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
// or the record of a changed line that is empty, which a renderer emits
// with nothing but the newline after it. Give each a cell of its own, so
// that the line is still recognizable as the diff line it renders rather
// than as nothing at all.
for _, payload := range b.ei.takeOrphanedMetadata() {
b.writeCells([]cell{{metadata: payload}})
}
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
b.ei.metadataConsumed = true
}
}
advanceToNextLine := func() {
b.wx = 0
b.wy++
b.seekWrite(0, b.wy+1)
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
// An OSC 1717 record describes the line it precedes and is never
// closed, so it stops applying at the line's end; a renderer emits a
// fresh one for each line it has something to say about.
b.ei.metadata.Reset()
}
if b.pendingNewline {
@@ -999,6 +1193,10 @@ func (b *viewBuffer) write(v *View, p []byte) {
b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
// The cells will hold what follows as an overwrite of what came
// before; the text as written keeps the return itself.
b.startAsWritten()
b.noteAsWritten(chr)
b.wx = 0
b.ei.notifyColumnReset()
default:
@@ -1114,8 +1312,18 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
truncateLine := false
isEscape, err := b.ei.parseOne(ch)
// A record that the next one superseded before any cell took it still
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
// a cell of its own, in the order they were emitted, ahead of whatever this
// character produces.
for _, payload := range b.ei.takeOrphanedMetadata() {
cells = append(cells, cell{metadata: payload})
}
if err != nil {
for _, chr := range b.ei.characters() {
characters := b.ei.characters()
for _, chr := range characters {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@@ -1124,6 +1332,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
}
cells = append(cells, c)
}
b.noteAsWritten([]byte(strings.Join(characters, "")))
b.ei.reset()
} else {
repeatCount := 1
@@ -1140,7 +1349,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, []cell{}
return truncateLine, cells
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
@@ -1149,10 +1358,19 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
repeatCount = cf.n
ch = []byte{' '}
width = 1
b.noteAsWritten(bytes.Repeat(ch, repeatCount))
} else if isEscape {
// do not output anything
return truncateLine, nil
// the escape itself outputs nothing, but any cells carrying an
// orphaned record still need writing
if len(cells) == 0 {
return truncateLine, nil
}
return truncateLine, cells
} else if characterEquals(ch, '\t') {
// The cells hold a tab as the spaces it fills; the text as written
// keeps the tab itself.
b.startAsWritten()
b.noteAsWritten(ch)
// fill tab-sized space
tabWidth := v.TabWidth
if tabWidth < 1 {
@@ -1161,14 +1379,20 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
ch = []byte{' '}
width = 1
repeatCount = tabWidth - (x % tabWidth)
} else {
b.noteAsWritten(ch)
}
c := cell{
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
metadata: b.ei.metadata.String(),
chr: string(ch),
width: width,
}
if c.metadata != "" {
b.ei.metadataConsumed = true
}
for range repeatCount {
cells = append(cells, c)
}
@@ -1744,6 +1968,88 @@ func (v *View) BufferLines() []string {
return lines
}
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
// to recover which line of which file it came from: the line's text, which can be
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
// records a diff renderer attached to it, which state the answer outright.
type DiffLineContent struct {
// The line's text as its writer wrote it, escape sequences left out, where
// BufferLines gives the text as the cells spell it. The two differ where the
// cells can't spell what was written: a tab, which they hold as the spaces it
// fills, and a carriage return, which they hold as the overwrite it caused.
// git terminates a path containing a space with a tab in a diff header, and a
// parser of the diff has to see the tab.
Text string
// The distinct OSC 1717 payloads carried by the line's cells, in
// left-to-right order. A single-column rendering tags every cell of a line
// with the same payload, so there is one; a side-by-side rendering tags
// each side separately, so a line showing a deletion beside the addition
// that replaces it carries both.
Metadata []string
}
// DiffLineContents returns the per-line material a diff-line reader works from
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
// snapshotted in a single locked pass, so they stay consistent with each other
// and with the buffer they came from even while a re-render rebuilds it.
func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
contents := make([]DiffLineContent, len(v.buf.lines))
for i := range v.buf.lines {
line := &v.buf.lines[i]
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
metadata = append(metadata, c.metadata)
}
}
contents[i] = DiffLineContent{Text: line.textAsWritten(), Metadata: metadata}
}
return contents
}
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
// the index of the corresponding line in the unwrapped internal buffer (as
// returned by BufferLines). Several view lines map to the same buffer line when
// that line wraps. Returns false if the view line is out of range.
func (v *View) BufferLineForViewLine(y int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.bufferLineForViewLine(y)
}
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
// first view line that renders it — the inverse of BufferLineForViewLine, for
// turning a line found by examining the buffer into a line to scroll to or
// select. Returns false if the buffer line isn't rendered into any view line.
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return first, ok
}
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
// the last view line that renders it, which for a line that doesn't wrap is the
// same as the first. It is where the far end of a range goes: a range is over
// buffer lines, so it has to cover the last one of them to its final segment
// rather than stopping where that line begins.
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return last, ok
}
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
@@ -1770,6 +2076,9 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return len(v.buf.lines)
}
@@ -1843,6 +2152,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) {
@@ -1970,16 +2288,26 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.buf.lines) == 0 {
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
return ""
}
return v.lineContentAtIdx(v.SelectedLineIdx())
return v.lineContentAtIdx(idx)
}
// expected to only be used in tests
@@ -1994,8 +2322,17 @@ func (v *View) SelectedLines() []string {
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; i++ {
lines = append(lines, v.lineContentAtIdx(i))
// The selection is in view lines, which count the segments a wrapped line
// is drawn as; a line the selection covers several segments of is still
// the one line it is.
idx, ok := v.bufferLineForViewLine(i)
if !ok || idx == previous {
continue
}
previous = idx
lines = append(lines, v.lineContentAtIdx(idx))
}
return lines
@@ -2005,6 +2342,19 @@ func (v *View) lineContentAtIdx(idx int) string {
return v.buf.lines[idx].cells.String()
}
// bufferLineForViewLine maps a view line index, which counts the wrapped
// segments of the lines it draws, to the index of the line of content it is a
// segment of. Only call this with a lock on writeMutex.
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
return 0, false
}
return v.viewLines[y].linesY, true
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()
@@ -2075,8 +2425,7 @@ func (v *View) ClearTextArea() {
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.buf.wx = 0
v.buf.wy = y
v.SetWritePos(0, y)
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
+339
View File
@@ -113,6 +113,99 @@ func TestWriteString(t *testing.T) {
}
}
func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
// The trailing newline is held back until more content arrives, so that the
// view doesn't end in an empty line.
v.writeString("a\nb\n")
v.OverwriteLines(0, "x")
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
}
// diffLineTexts returns the Text of each of the given contents.
func diffLineTexts(contents []DiffLineContent) []string {
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
}
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
tests := []struct {
name string
stringsToWrite []string
expectedShown []string
expectedAsWritten []string
}{
{
name: "a line the cells spell as written",
stringsToWrite: []string{"abc\n"},
expectedShown: []string{"abc"},
expectedAsWritten: []string{"abc"},
},
{
name: "a tab is kept rather than the spaces it fills",
stringsToWrite: []string{"a\tb\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "a carriage return is kept rather than the overwrite it causes",
stringsToWrite: []string{"abc\rde\n"},
expectedShown: []string{"dec"},
expectedAsWritten: []string{"abc\rde"},
},
{
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
name: "escape sequences are left out",
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
expectedShown: []string{"+foo"},
expectedAsWritten: []string{"+foo\r"},
},
{
// ConPTY writes a run of spaces as a cursor-forward escape.
name: "a cursor-forward escape stands for the spaces it skips",
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
expectedShown: []string{" a b"},
expectedAsWritten: []string{"\ta b"},
},
{
name: "a line written in two parts",
stringsToWrite: []string{"a\t", "b\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "only the lines with a tab or a return are kept separately",
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
expectedShown: []string{"x", "y z", "w"},
expectedAsWritten: []string{"x", "y\tz", "w"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
for _, s := range test.stringsToWrite {
v.writeString(s)
}
assert.Equal(t, test.expectedShown, v.BufferLines())
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
})
}
}
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
v.writeString("a\tb\nc\td")
// Overwriting a line starts it over: what it kept of its earlier text goes,
// and the line below is left alone.
v.OverwriteLines(0, "xy")
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
}
func TestUpdatedCursorAndOrigin(t *testing.T) {
tests := []struct {
prevOrigin int
@@ -158,6 +251,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
func osc1717(payload string) string {
return "\x1b]1717;" + payload + "\x1b\\"
}
func TestDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer prefixes each line it renders with a record naming the
// file and the line's position in it: version;type;new-line;old-line;file.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "line1",
osc1717("1;d;2;2;foo.txt") + "old2",
osc1717("1;a;2;;foo.txt") + "new2",
"@@ a hunk header, which carries no record @@",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
// The record of the line before doesn't bleed onto this one.
{Text: "@@ a hunk header, which carries no record @@"},
}, v.DiffLineContents())
}
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A side-by-side renderer puts two diff lines on one rendered line, and so
// emits a record before each half.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
}, "\n"))
assert.Equal(t, []DiffLineContent{
// The two halves of a context line are the same diff line, stated twice.
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A line that gocui wraps is still one buffer line, so its record covers
// every view line it is displayed on.
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
assert.Equal(t, []DiffLineContent{
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
assert.Equal(t, 3, v.ViewLinesHeight())
for viewLine := range 3 {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, 0, bufferLine)
}
}
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString(strings.Join([]string{
// A banner announcing a file and its first hunk at once carries both
// records back to back.
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
// So does a modification whose deletion and addition are collapsed into
// a single rendered line.
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
// A changed line that is empty is rendered as its record and nothing else.
osc1717("1;a;6;;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, []DiffLineContent{
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer announces itself with a version-only record before the
// diff. It must leave no trace: no visible bytes, no line of its own, and
// above all no record on the line that follows it.
v.writeString(osc1717("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc1717("1;a;1;;foo.txt") + "added",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "diff --git a/foo.txt b/foo.txt"},
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
@@ -204,6 +398,61 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
func TestBufferLineForViewLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, wantBufferLine, bufferLine)
}
_, ok := v.BufferLineForViewLine(5)
assert.False(t, ok)
_, ok = v.BufferLineForViewLine(-1)
assert.False(t, ok)
}
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the first of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 1, 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestLastViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the last of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 3, 4} {
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.LastViewLineForBufferLine(3)
assert.False(t, ok)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
@@ -780,3 +1029,93 @@ 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))
})
}
}
// A view that wraps draws one line of its content as several view lines, and the
// cursor and the range anchor count those. What is asked about a selection is
// which lines of the content it covers, so those are what it has to be reported
// in.
func TestSelectedLinesOfWrappedContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// "a line that wraps" takes two view lines, so the four lines of content are
// drawn as five: "one", "two", "a line th", "at wraps", "four".
v.writeString("one\ntwo\na line that wraps\nfour\n")
assert.Equal(t, 5, v.ViewLinesHeight())
// The cursor on the wrapped line's second half is on that line.
v.FocusPoint(0, 3, false)
assert.Equal(t, "a line that wraps", v.SelectedLine())
// A range over both halves of the wrapped line covers one line of content.
v.SetRangeSelectStart(2)
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
// Resizing a view throws away the wrapping of its content and wraps it again for
// the new width, which moves every line of it to a different view line. The
// positions into the view count view lines, so they all have to come along.
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
g := &Gui{}
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// Two wrapping lines, with a single line between them: eight view lines for
// five lines of content.
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
assert.Equal(t, 8, v.ViewLinesHeight())
// A range over the whole of the second wrapping line, which is drawn as view
// lines 4 to 6.
v.SetRangeSelectStart(4)
v.FocusPoint(0, 6, false)
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
// Widen the view so that nothing wraps any more.
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
assert.Equal(t, 5, v.ViewLinesHeight())
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
}
+14 -3
View File
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.updateSelectionHighlights()
self.UpdateSelectionHighlights()
c.HandleFocus(opts)
}
// updateSelectionHighlights re-derives which views draw a selection, and which of
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) updateSelectionHighlights() {
func (self *ContextMgr) UpdateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
@@ -399,3 +399,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
panic("context not in stack")
}
// IsInStack reports whether the given context is on the stack at all, for callers
// that can't otherwise know and would make NextInStack panic.
func (self *ContextMgr) IsInStack(c types.Context) bool {
self.RLock()
defer self.RUnlock()
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
return other.GetKey() == c.GetKey()
})
}
+18 -26
View File
@@ -13,15 +13,14 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@@ -34,9 +33,8 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
)
var _ types.IBaseContext = &BaseContext{}
@@ -122,6 +120,14 @@ func (self *BaseContext) HasSelectableContent() bool {
return self.hasSelectableContent
}
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
// list length either: the main panes, which can only tell by reading the diff they
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
// ContextMgr.UpdateSelectionHighlights).
func (self *BaseContext) SetHasSelectableContent(value bool) {
self.hasSelectableContent = value
}
func (self *BaseContext) GetKey() types.ContextKey {
return self.key
}
@@ -153,7 +159,6 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
@@ -175,15 +180,6 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
}
self.onClickFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnDoubleClick() func() error {
return self.onDoubleClickFn
}
@@ -192,10 +188,6 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
if fn != nil {
if self.onRenderToMainFn != nil {
+8 -3
View File
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
+8 -3
View File
@@ -31,11 +31,16 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
+62 -7
View File
@@ -8,9 +8,62 @@ import (
type MainContext struct {
*SimpleContext
*SearchTrait
diffSelect types.DiffSelectState
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
// drag that follows can anchor its range there. The click may have selected a whole
// hunk, whose range anchor is the block's far end, so the clicked line can't be
// read back from the view.
dragAnchorViewLine int
// selectableContentRenderKey names the render whose content HasSelectableContent
// was worked out from. What there is to select is a property of the content, so an
// answer about the content of another render says nothing about this one.
selectableContentRenderKey string
}
var _ types.ISearchableContext = (*MainContext)(nil)
var (
_ types.ISearchableContext = (*MainContext)(nil)
_ types.DiffPaneContext = (*MainContext)(nil)
)
// DiffSelectState returns the focused main view's selection mode state, for the
// controllers to read and mutate directly.
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
return &self.diffSelect
}
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
// line, no range — for whenever it is established from scratch rather than moved. The
// view's range anchor is cleared too, so the next render highlights the cursor line
// only.
func (self *MainContext) ResetDiffSelectMode() {
self.diffSelect.Mode = types.DiffSelectModeLine
self.diffSelect.RangeIsSticky = false
self.diffSelect.UserEnabledHunkMode = false
self.GetView().CancelRangeSelect()
}
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag
// that follows can anchor its range there (see dragAnchorViewLine).
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
self.dragAnchorViewLine = viewLine
}
// DragAnchorViewLine returns the view line the last mouse-down landed on.
func (self *MainContext) DragAnchorViewLine() int {
return self.dragAnchorViewLine
}
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
// selectableContentRenderKey).
func (self *MainContext) SelectableContentRenderKey() string {
return self.selectableContentRenderKey
}
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
func (self *MainContext) SetSelectableContentRenderKey(key string) {
self.selectableContentRenderKey = key
}
func NewMainContext(
view *gocui.View,
@@ -21,12 +74,11 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HasSelectableContent: false,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -38,5 +90,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
return nil
}
// When selecting a search result, collapse a range selection (whether sticky or not)
// or a hunk selection to just the matching line.
func (self *MainContext) OnSearchSelect(int) {
self.ResetDiffSelectMode()
}
+4
View File
@@ -89,6 +89,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
})
}
if item.FilterColumns != nil {
return item.FilterColumns
}
return item.LabelColumns
},
)
+10 -8
View File
@@ -14,10 +14,15 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },
@@ -27,13 +32,10 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
)
getDisplayStrings := func(startIdx int, endIdx int) [][]string {
commits := viewModel.GetItems()
if startIdx >= len(commits) {
return nil
}
return presentation.GetReflogCommitListDisplayStrings(
commits[startIdx:endIdx],
viewModel.GetItems(),
startIdx,
endIdx,
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().CherryPicking.SelectedHashSet(),
c.Modes().Diffing.Ref,
+7 -2
View File
@@ -12,10 +12,15 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
)
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,
) *StashContext {
+8 -3
View File
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+7 -2
View File
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
+2 -3
View File
@@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() {
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() {
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
DiffLine: diffLineHelper,
Repos: reposHelper,
RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
@@ -176,7 +178,6 @@ func (gui *Gui) resetHelpersAndControllers() {
contextLinesController := controllers.NewContextLinesController(common)
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
branchesController := controllers.NewBranchesController(common)
gitFlowController := controllers.NewGitFlowController(common)
@@ -313,13 +314,11 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Normal,
mainViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
)
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
secondaryViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
)
controllers.AttachControllers(gui.State.Contexts.Files,
-1
View File
@@ -8,7 +8,6 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
-4
View File
@@ -19,10 +19,6 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
+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,
@@ -606,16 +606,6 @@ func (self *CommitFilesController) expandAll() error {
return nil
}
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
+3 -13
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()),
}
}
@@ -408,16 +408,6 @@ func (self *FilesController) GetOnDoubleClick() func() error {
})
}
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
// if we are dealing with a status for which there is no key in this map,
// then we won't optimistically render: we'll just let `git status` tell
// us what the new status is.
+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
})
}

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