The integration README still described the raw `go run cmd/integration_test`
and `go test` invocations, which are easy to get wrong (the headless go-test
command in particular) and don't match how we actually run the tests. Rewrite
the running/debugging/sandbox instructions around the justfile's e2e recipes
instead, and point at the optional zsh completion script. Also switch the
test-list regeneration hint to `just generate`, matching the rest of our docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
just's completion is clap-dynamic and exposes no hook for completing a
recipe's arguments, so `just e2e <Tab>` couldn't suggest anything. Wrap
just's completer: for the e2e/e2e-cli recipes, complete the test names
found under pkg/integration/tests/, delegating everything else back to
just.
The names are fed to _multi_parts so they complete one "/"-separated
segment at a time — an empty <Tab> offers just the categories, then
drills into the tests within a category — and the .go extension and the
shared helper files are stripped so the candidates are exactly the names
the recipe accepts.
Source it from ~/.zshrc (after compinit) to enable; it's a no-op without
just installed and only activates inside a repo with a justfile and a
pkg/integration/tests/ directory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`just e2e` was the visible-UI runner, but it's only useful for a single test
(and even then only with --sandbox/--slow); running it without arguments is far
too slow, yet it was easy to invoke by reflex when `just e2e-all` (run all
headlessly) was meant.
Make `just e2e` the everyday headless runner: no arguments runs the whole
suite (what e2e-all did), and a test name runs just that one headlessly via
`go test -run` — which we had no target for before. The visible-UI runner moves
to `e2e-cli`, pairing with the existing `e2e-tui` (the two main.go subcommands).
e2e-all is now redundant and removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the recently added external change detection, it happens more often
now that we refresh the commits list because an agent made a commit in
the background. In this case, if we keep the selection index the same,
it now points at a different commit, making the main view show a
different commit too, which is confusing and annoying. To fix this,
track the selected commit and range anchor by hash before reloading,
then restore those rows if both hashes still exist. This also allows us
to get rid of some bespoke code that did this for the specific cases of
reverting a commit or cherry-picking commits, because those are now
handled by the generic mechanism.
Starting an interactive rebase (the `edit` command and quick-start) used
to capture the selected commit range by hash before starting the rebase
and restore it afterwards, because new update-ref lines for stacked
branches can shift the commits' positions in the list. The generic
keep-selection-by-hash mechanism now does exactly this for every refresh,
including these, so the bespoke code is redundant.
This relies on the previous commit, which taught the generic matcher to
handle the case where the selected commit turns into a rebase TODO entry
while it's being edited - something the bespoke code handled implicitly
by matching on hash alone.
When restoring the commit selection after a refresh we match by hash and
TODO status. The TODO status is part of the match so that a commit being
reverted or cherry-picked is matched to the real commit rather than to
the rebase TODO entry that shares its hash.
But a selected commit can also change its TODO status across a refresh:
when starting an interactive rebase that stops to edit it, the real
commit becomes a TODO entry. Fall back to matching by hash alone when
there is no exact match, so the selection is still restored in that case.
The next commit relies on this to remove bespoke selection-restoration
code in the local commits controller that matched by hash alone, which
the generic mechanism otherwise wouldn't fully replace.
With the recently added external change detection, it happens more often
now that we refresh the commits list because an agent made a commit in
the background. In this case, if we keep the selection index the same,
it now points at a different commit, making the main view show a
different commit too, which is confusing and annoying. To fix this,
track the selected commit and range anchor by hash before reloading,
then restore those rows if both hashes still exist. This also allows us
to get rid of some bespoke code that did this for the specific cases of
reverting a commit or cherry-picking commits, because those are now
handled by the generic mechanism.
Preparation for the next commit, which selects the newly created commit
after a commit succeeds, while leaving the selection alone on failure.
For now success and failure use the same refresh options, so behavior is
unchanged.
This guards against regressions from the changes that follow. We're
about to add a mechanism that keeps the selection anchored by commit
hash, but we need to make sure that it doesn't take effect here; after a
merge we want to select the newly added merge commit. In the current
state of the code this happens to work because we keep the selection
index the same, which happened to be 0 here; later we will change this
to explicitly select the head commit after the merge.
In a large repo, when touching (editing) more and more files, staging
hunks in lazygit could become slower and slower over time. Specifically,
this happened when you edited a lot of files and then discarded their
changes again. I have seen cases where staging a hunk began to take
seconds; the fix then was to type `git status` on the command line once,
this made it fast again.
The reason was that lazygit was trying too hard to be a good git
citizen, and used the `GIT_OPTIONAL_LOCKS=0` env var on every git
command it made. The consequence was that it never updated the mod date
cache in git's index file, which caused git to rehash every file whose
mod date doesn't match what it recorded in the index, on every refresh.
Typing `git status` updates that cache, which is why this was a
workaround.
Fix this by using the `GIT_OPTIONAL_LOCKS=0` flag only for refreshes
that are running unattended in the background, i.e. the periodic
autoRefresh and the newly external change detection. For those it is
important because it avoids "cannot lock index" errors for commands that
the user might issue at the same time. All other refreshes are user
initiated and no longer use the flag, which is in line with what `git
status` does, so this keeps performance from deteriorating over time.
The wrapper existed to add a git-specific env var to every command. Now
that that's gone, its New/NewShell/Quote methods just delegated to the
inner builder. The only remaining git-specific behavior — the command
runner — is attached in the constructor via CloneWithNewRunner, which
already returns a complete builder, so we can return that directly and
drop the wrapper struct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
We set GIT_OPTIONAL_LOCKS=0 for every git command we run. That env var
only affects `git status`: it tells git not to take the optional lock it
would otherwise use to write the index back after refreshing the cached
stat information. The intent was to avoid contending for index.lock with
git commands the user runs in a terminal.
The downside is that our `git status` never persists the refreshed
stat-cache. So whenever the working tree's cached stat info goes stale
(e.g. editing files and discarding the changes, or a checkout), every
subsequent status re-hashes the affected files to confirm they're clean,
and stays slow until something else writes the index (such as the user
running `git status` in a terminal).
Fix this by only suppressing optional locks for refreshes that run
unattended in the background; foreground refreshes triggered by a user
action now run a plain `git status` that writes the refreshed index back,
just like the command line does. Background refreshes keep passing
--no-optional-locks so they still can't cause lock contention.
RefreshOptions gains a Background flag that the background routines set,
threaded down to the status command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lazygit refreshes its UI when its terminal window gets the focus, which
is enough for the situation where the user makes a commit in their IDE
or in another git client. It isn't enough for the case that a coding
agent makes commits in the background. Improve this by doing a
light-weight poll of the git state in the background, and refresh when a
change is detected.
This adds two settings to control the mechanism:
- `git.autoDetectExternalChanges` (default true) is the on/off switch,
parallel to autoFetch/autoRefresh
- `refresher.externalChangeCheckInterval` (default 2 seconds) is the
poll cadence
Addresses #5554.
Add a 2-second background poll that calls Status.RefsSnapshot and
compares against the snapshot stored at the end of the last refs-
touching refresh. On a diff, trigger a full refresh — same scope as the
focus-in handler, because once we know something changed externally
we can't be sure what (an agent might have created a worktree or
stashed something alongside the commit we detected).
Refresh runs in SYNC mode because goEvery already serializes iterations
via <-done: a slow refresh delays the next tick naturally instead of
letting work stack. The post-refresh hook from the previous commit
updates the snapshot, so in-app commands don't cause the next poll to
spuriously re-fire.
Disabled in the integration test config, like autoRefresh and autoFetch,
because demo replays make repo changes throughout the run; at 2-second
cadence the resulting full refreshes compete with the demo's own
choreography and push some demos past their 40-second timeout.
Also list the two new config keys in checkForChangedConfigsThatDontAutoReload
so a config edit warns the user that lazygit needs a restart.
Add the storage and snapshot-update half of the external-change-detection
mechanism. RefreshHelper now keeps a mutex-protected snapshot string and exposes
accessors for it; Refresh captures a fresh snapshot at the start of any refresh
whose scope set includes COMMITS or BRANCHES.
We capture before reading the git state, not after. Capturing after would let an
external change that lands between the git state read and the snapshot (say, the
next step of a rebase running in another terminal) leave the stored snapshot
newer than what we actually rendered; the poller would then see no difference
and never refresh again, stranding the UI on the intermediate state. Capturing
first keeps the snapshot from running ahead of the render, so if disk moves
during the refresh the next poll catches it.
No reader of the snapshot exists yet — the polling goroutine that consumes it
comes in a later commit. Keeping the snapshot hook in its own commit isolates
the invariant that the snapshot stays in sync with what the UI has observed,
which is what makes the poller's change-detection predicate work across in-app
commands and focus-in refreshes.
Two settings to control the upcoming background polling mechanism:
- git.autoDetectExternalChanges (default true) is the on/off switch, parallel to
autoFetch/autoRefresh
- refresher.externalChangeCheckInterval (default 2 seconds) is the poll cadence
Disabling is the bool's job, not a magic 0 interval, matching the existing
convention.
Not yet referenced by any code.
A cheap fingerprint of local branches and HEAD that future code can poll to
detect when refs have moved externally.
Branches come from a porcelain for-each-ref. HEAD is read directly from
.git/HEAD: that avoids spawning a child process and captures the symref-or-hash
distinction we need to tell "detached at X" apart from "on a branch pointing at
X" — they share a commit hash, which is exactly the situation at the end of a
rebase when HEAD reattaches to the branch. The reftable backend doesn't keep a
real .git/HEAD (it writes a fixed stub), so when we see that stub or the file is
unreadable we fall back to porcelain commands, which are backend-agnostic.
Uses DontLog so a future polling caller won't spam the command log. Not yet
wired up to any caller.
Several downstream conditions in Refresh() relied on multi-scope predicates to
express "if X is in scope, Y also needs refreshing". This makes it hard to add
new code that needs to ask "does this refresh re-read refs?", because the answer
involves mirroring one of those predicates and keeping them in sync forever.
Expand the co-refreshing relationships once, up front, right after the scope set
is built. The downstream conditions then collapse to single-scope checks against
the (now-expanded) set. Behavior is preserved.
Two of the scattered multi-scope conditions are intentionally left as-is because
they express subsumption rather than co-refresh (one branch already does the
work of another internally — expanding would cause double-refresh), and one
expresses mid-function coupling on a flag set inside the COMMITS/BRANCHES block.
The schema annotated refreshInterval and fetchInterval with minimum=0,
but the background routines reject a value of 0 (they require
interval > 0 and otherwise log it as invalid and disable the feature).
So 0 is not actually a valid value; switch to exclusiveMinimum=0 so the
schema matches what the code accepts.
Several commands (rewording or amending an earlier commit, custom patch
operations, etc.) are implemented by starting an interactive rebase that
stops at a commit, amending it, and continuing. When no conflict occurs,
the user isn't meant to notice a rebase happened at all.
But a background file refresh can fire while the rebase is mid-flight
and render a dirty working copy of whatever the behind-the-scenes rebase
is doing (e.g. applying a custom patch).
To fix this, we pause the background routines for the duration of any
waiting-status operation — exactly the window in which lazygit is
driving the git operation itself and will refresh once at the end. The
boundary is also right for the conflict case: when a rebase stops on a
conflict the operation returns, the pause releases, and background
refreshes resume for the interactive resolution that follows.
Several commands (rewording or amending an earlier commit, custom patch
operations, etc.) are implemented by starting an interactive rebase that stops
at a commit, amending it, and continuing. When no conflict occurs, the user
isn't meant to notice a rebase happened at all.
But a background file refresh can fire while the rebase is mid-flight and render
a dirty working copy of whatever the behind-the-scenes rebase is doing (e.g.
applying a custom patch).
To fix this, we pause the background routines for the duration of any
waiting-status operation — exactly the window in which lazygit is driving the
git operation itself and will refresh once at the end. The boundary is also
right for the conflict case: when a rebase stops on a conflict the operation
returns, the pause releases, and background refreshes resume for the interactive
resolution that follows.
Replace the pauseBackgroundRefreshes bool with a count. The single existing
caller (subprocess suspend/resume) is unaffected, but we're about to add a
second, independent reason to pause — lazygit driving a git operation that the
background routines would otherwise catch mid-flight — and the two scopes can
overlap. A bool can't represent "two things both want refreshes paused"; a count
can.
When using delta as a custom pager, and the view is so narrow that delta
needs to wrap long lines, only the last segment of such a wrapped line
would have its background color extended to the right edge of the view,
resulting in a block of lines with a frayed right edge, like this:
<img width="623" height="218" alt="Screenshot 2026-06-18 at 17 25 36"
src="https://github.com/user-attachments/assets/346156af-212f-44c3-b8aa-2a66c23ed836"
/>
Fix this by extending the wrapped line's background colors to the right
edge as well, making it look like this:
<img width="623" height="218" alt="Screenshot 2026-06-18 at 17 25 23"
src="https://github.com/user-attachments/assets/9af46ba3-701b-47ce-b38d-bdffaf5182fa"
/>
Tools like delta paint each diff line's background with '\x1b[K' so
the color reaches the right edge. Up to now the '\x1b[K' handler
appended (InnerWidth - cx) explicit padding cells with the fill bg
so rendering picked up the color. That worked for short lines but
silently degraded once content exceeded InnerWidth: the repeat
count went non-positive, no cells were added, and after wrapping
the partial tail segment was left without any cells carrying the
fill color, so draw() fell back to the view's default bg.
Record the fill colors on the source line as optional
trailingFillAttributes. In the '\x1b[K' handler set them (and drop
the padding-cell loop — the metadata covers both the wrap and the
non-wrap cases). In draw(), once per source line, pick the trailing
cell's fg/bg from the metadata if present and otherwise from the
view defaults; then the inner-loop fills past-content cells with
that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sentinel was appended to every \n-terminated line solely so that
draw()'s prevFgColor tracking would reset to default for the trailing
area; without it, an AttrReverse-styled last cell would carry its
rendered bg past the end of the line.
The same prevFgColor mechanism propagated AttrReverse past content on
*unterminated* lines too — which doesn't match real terminal behavior
(try `print '\x1b[7m\x1b[31mfoo'` in a shell: the reverse stops at
the last character) and isn't relied on by anything in lazygit, since
all our writers terminate lines with \n.
Drop the sentinel cell, drop prevFgColor, and just have draw() paint
trailing cells with the view's default fg/bg. The
TestUnterminatedReverseLineExtendsToEdge regression test inverts to
document the new (terminal-matching) behavior, renamed accordingly.
TestWriteString expectations also drop the trailing "" that came
from the sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cells of a source line will soon need to carry metadata about how
the line was terminated (newline vs filled to edge via \x1b[K). Move
to a struct so there's somewhere to put it; this commit only renames
[][]cell to []line{cells: ...} with no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four ReplaceAll(str, "\x00", "") calls (and the equivalent
rune-by-rune skip in linesToString) are leftover from when cell.chr
was a rune and \x00 was used as an internal sentinel. With chr now
being a string and no code path writing \x00, the filtering never
strips anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tools like delta emit each diff line with the bg color set, then
\x1b[K to fill the rest of the row with that bg color. When the
content fits within the view's inner width, gocui's \x1b[K handling
appends explicit padding cells and rendering works. When the content
exceeds the inner width, \x1b[K adds no cells (negative repeat
count), the line is wrapped into multiple segments, and the partial
tail segment's trailing cells fall back to the view default bg
instead of continuing the fill color.
Add a test that drives draw() against a tcell mock terminal and
asserts the current (buggy) trailing background.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The next few commits restructure how the view's draw() decides the
fg/bg of cells past the end of a line's content. Pin down three
existing behaviors first so the restructuring stays a refactor:
- '\n' should reset attributes for the trailing area so a reversed
final cell doesn't bleed into empty space.
- An unterminated line with AttrReverse on its last cell should
propagate that to the right edge (otherwise the rendered bg
abruptly stops at the last character).
- '\x1b[K' on a line that fits within InnerWidth should fill the
remaining cells with the current bg color.
Introduce a small WithSimulationScreen helper that swaps in a tcell
mock terminal so tests can call view.draw() and inspect rendered
cells via Screen.Get().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lazygit lets you configure multiple pagers and switch between them with
the `|` key. The changes in this PR improve this for the case that you
have more than two.
- **You can see which pager you switched to.** The notification used to
just say "pager 2 of 3"; now it shows the pager's name, so you no longer
have to remember the order to know where you've landed.
- **You can name your pagers.** By default the name is taken from the
pager command, but you can set your own name in the config. This helps
when two entries run the same command with different options (for
example plain `delta` and `delta --side-by-side`).
- **You can cycle backwards.** Alongside `|`, which moves to the next
pager, the new `\` key moves to the previous one — so you can step back
instead of going all the way around the list to return to one you just
passed. This is especially useful when you have two pagers that you
alternate between often (e.g. `delta` and `delta --side-by-side`), but
also have several others in the list that you use only occasionally.
- **Invalid pager setups are caught early.** If an entry combines
options that can't be used together, lazygit now tells you about it on
startup instead of silently producing a broken diff.
With more than a couple of pagers, having to cycle forward through all
of them to reach the previous one (or to back out of an accidental press
of `|`) is tedious. Add a second binding that cycles backward.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reverse-cycle handler is about to need the same re-render-and-toast
logic. Pull it out first so the behavior change that follows only has to
swap the cycle direction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When cycling pagers, "Selected pager 2 of 3" gives no clue which pager
you landed on; with several configured you have to remember the order.
Include the pager's name in the toast instead.
The name is normally derived from the first word of the pager command,
but that isn't always enough: two entries can share a command but differ
in options (e.g. "delta" and "delta --side-by-side"), and an entry may
have no command at all (the default entry, or when using
useExternalDiffGitConfig). So add an optional `name` field that
overrides the derived name.
The message was also hardcoded in English; localize it while we're here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pager (GIT_PAGER) formats the diff git produces, while
externalDiffCommand and useExternalDiffGitConfig change how git produces
the diff in the first place. They are different pipeline stages, not
alternatives, so combining them on one entry just pipes one through the
other and produces garbled output (e.g. delta trying to parse
difftastic's side-by-side output as a unified diff). The two external
mechanisms likewise conflict, with the explicit command silently
shadowing the git config one. Treat all three as mutually exclusive and
reject configs that set more than one on the same entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>