Commit graph

7589 commits

Author SHA1 Message Date
Stefan Haller 3cf890b7d7 Pause background refreshes while driving a git operation
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.
2026-06-19 17:56:48 +02:00
Stefan Haller f9c81b655d Make background-refresh pausing reentrant
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.
2026-06-19 17:56:48 +02:00
Stefan Haller c81c653c55
Fix coloring of wrapped delta lines (#5711)
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"
/>
2026-06-18 17:32:11 +02:00
Stefan Haller 51b409383c Extend the fill background to wrapped tail segments
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>
2026-06-18 17:22:31 +02:00
Stefan Haller 9c8a02f901 Remove the '\n' sentinel cell
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>
2026-06-18 17:22:31 +02:00
Stefan Haller ad8335aaa8 Wrap v.lines cells in a line struct
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>
2026-06-18 17:22:31 +02:00
Stefan Haller 1788eaa91f Rename lineType to cells
This is to free up the name lineType for something else in the next commit.
2026-06-18 17:22:31 +02:00
Stefan Haller d234d8b358 Remove dead \x00 filtering from string conversion helpers
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>
2026-06-18 17:22:31 +02:00
Stefan Haller 6c55823492 Demonstrate broken background fill on wrapped \x1b[K-padded lines
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>
2026-06-18 17:22:31 +02:00
Stefan Haller 2f18db4377 Add regression tests for trailing-fill rendering
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>
2026-06-18 17:21:27 +02:00
Stefan Haller 69004ce208
Some fixes to our infrastructure (#5705)
Minor things, mainly to improve the experience on Windows; see
individual commit messages.
2026-06-16 16:28:41 +02:00
Stefan Haller 643f169be2 Don't include integration tests in "just test" on Windows
This allows running "just check" on Windows, it just doesn't check quite
as much.
2026-06-16 16:21:13 +02:00
Stefan Haller fcce4de6fc Allow running check_script.sh when there are uncommitted changes 2026-06-16 16:21:13 +02:00
Stefan Haller df171722cb Make .go files have LF line endings on Windows
Since gofumpt expects and emits LF even on Windows, this makes it easier for
agents to gofumpt their files.
2026-06-16 16:21:13 +02:00
Stefan Haller 061665726e Fix Windows linter errors
Apparently we don't check Windows-only code for linter errors on CI.
2026-06-16 16:21:13 +02:00
Stefan Haller 8a7bef0975
Improve cycling through multiple pagers (#5678)
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.
2026-06-07 16:00:08 +02:00
Stefan Haller 8534a05a2e Allow cycling pagers in reverse
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>
2026-06-07 15:51:57 +02:00
Stefan Haller 5a4247b234 Extract onPagerChanged helper from cyclePagers
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>
2026-06-07 15:51:57 +02:00
Stefan Haller 6316094d58 Show pager name in the cycle-pager toast, and let users name pagers
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>
2026-06-07 15:51:57 +02:00
Stefan Haller 81420ce362 Reject pager entries that combine multiple diff mechanisms
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>
2026-06-07 15:51:56 +02:00
Stefan Haller 9739a43355 Terminate tooltip text with a full stop
This is our general convention for tooltips, it was just forgotten here.
2026-06-07 15:51:56 +02:00
Stefan Haller badb089a86 Add a "just check" command 2026-06-07 15:51:56 +02:00
Stefan Haller 68f3bcf53b
Fix unstaging a submodule with dirty content (#5666)
Staging and unstaging submodules didn't work properly when the submodule
had uncommitted changes of its own (modified or untracked files inside
it). This PR fixes a few related problems:

- **Unstaging a submodule that has both a new commit and uncommitted
changes now works.** Previously, staging such a submodule left it
half-staged, and from there the stage key would only ever try to
re-stage it — there was no way to unstage it again. Now the stage key
toggles it back to unstaged as expected.

- **Trying to stage a submodule that has nothing stageable now explains
why.** When a submodule's only changes are uncommitted content inside it
(with no new commit), there's nothing the parent repository can stage.
Instead of the keypress silently doing nothing (except briefly flashing
the status to staged and then back to unstaged), lazygit now shows an
error explaining that you need to commit inside the submodule first.

- **The stage key (space) and the stage-all key (`a`) now behave
consistently.** All of the above applies equally whether you act on the
submodule directly or use "stage all", and "stage all" no longer gets
stuck or behaves differently from the stage key just because a submodule
with uncommitted changes is present in the list.

Fixes #3641.
2026-06-04 09:16:49 +02:00
Stefan Haller 785c8a712c Explain when a submodule has nothing stageable
A submodule that only has dirty or untracked content (no new commit) can't
be staged from the parent repo, but it still shows up as having unstaged
changes. Pressing stage on it therefore briefly flashed as staged and then
reverted, without explaining why nothing was staged.

Detect this case (via `git submodule status`, where a '+' prefix marks a
stageable commit change) in the shared stage/unstage decision: if the only
thing that looks stageable is such a submodule, don't try to stage it.
Instead unstage if there's anything staged to unstage, so the toggle stays
symmetric; otherwise show an error explaining that there's nothing to stage.

Because the decision is shared, this covers both the stage (space) and
stage-all (a) keybindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller 8b5cfb0425 Optimistically render unstaging a dirty submodule
This map only feeds the optimistic rendering that makes staging feel
instant; it doesn't affect the eventual status, which git reports after
the refresh. The "MM" entry can never be reached for a regular file: a
file at "MM" has stageable unstaged changes, so pressing space stages it
rather than unstaging, and the unstage path is where this map is used. The
only thing that reaches the unstage path at "MM" is a submodule whose
commit is staged on top of dirty content, so this entry exists purely to
update that submodule instantly instead of waiting for the next git
status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller c46c874442 Also verify stage-all can unstage a dirty submodule
Before the staging decision was unified, the stage (space) and stage-all
(a) keybindings each made their own decision, so a fix to one wouldn't
reach the other. Extend the test to drive the submodule through stage-all
as well, guarding against that asymmetry coming back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller 3f0a7512f8 Fix unstaging a submodule with dirty content
The stage/unstage toggle decides what to do based on whether a node has
unstaged changes: if it does, it stages; otherwise it unstages. For a
submodule this breaks down, because dirty or untracked content inside the
submodule always reports as an unstaged change in the parent repo but can
never be staged from there. Once such a submodule's commit pointer is
staged it sits at "MM", and every subsequent press keeps trying to stage
the unstageable dirty content, so it can never be unstaged.

Treat a submodule's unstaged change as stageable only when its commit
isn't already staged, so that a staged submodule unstages on the next
press regardless of leftover dirty content. Because the decision is now
shared by press and stage-all, this fixes both at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller 66fe18dd59 Unify the stage/unstage decision for press and stage-all
pressWithLock (acting on the selection) and toggleStagedAllWithLock (acting
on the whole tree) each independently decided whether to stage or unstage,
ran the optimistic update, and logged the action. That duplicated decision
has already drifted: the tracked-files filter was added to press months
before it was applied to stage-all, and fixes to one have repeatedly had to
be chased into the other.

Extract that shared decision into toggleStaged, leaving each caller to
supply only the git commands it runs (per-path for the selection, bulk
add -A / reset for the whole tree — the latter is required because the tree
root node has an empty path, so a per-path stage wouldn't work). This is a
pure refactor: the two callers' decisions were already equivalent, so
behavior is unchanged. It exists so the next change to the staging logic
only has to be made once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller c588c5507c Add a test demonstrating that you can't unstage a dirty submodule
When a submodule has both a new commit (which the parent repo can stage)
and dirty working-tree content (which it can't), staging it lands on a
"MM" status. Pressing space again should unstage it, but instead it tries
to stage the dirty content over and over, so you can never get back to an
unstaged state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00
Stefan Haller 38526c9ec4
Add direnv support (#5660)
Supports loading direnv's environment files (`.envrc`) when switching
repos or worktrees, or when entering or exiting submodules.

There's no configuration for this; the functionality is automatically
enabled when direnv is installed.

Closes #3653.
2026-06-04 09:10:50 +02:00
Stefan Haller b76c1072ff Offer direnv .envrc approval from inside lazygit
When a user switches into a repo whose .envrc hasn't been approved with
`direnv allow`, the previous behavior was to drop a "blocked" error
popup and leave the user to fix it externally. That meant opening a
terminal, running `direnv allow`, and then either restarting lazygit or
switching repos and back to refresh the env — easy to get wrong, easy
to forget.

When `direnv export json` exits non-zero, follow up with `direnv status
--json` to ask direnv whether the current directory has a not-yet-
allowed .envrc, and if so, get its path. Then show a confirmation popup
with the .envrc contents inline so the user can read what they're
approving. Confirming runs `direnv allow <path>` and re-runs the load
so the new env reaches subprocesses immediately; cancelling leaves the
env unloaded (the same state as before this commit when direnv refused
to load the .envrc).

Using `direnv status --json` instead of parsing the "is blocked"
stderr line means we rely on direnv's structured output rather than
its human-readable error format, which is more stable across versions
and avoids assumptions about output formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 09:05:01 +02:00
Stefan Haller bb8955f2de Load direnv environment when switching repos
When a user opens a repo from the recent-repos menu or jumps between
worktrees inside lazygit, only the env vars present at process startup
reach subprocesses. That breaks pre-commit hooks and other tools whose
dependencies are pulled in by a per-repo .envrc — users were left with
read-only operations because the env their shell would normally load via
direnv never made it into lazygit's git invocations.

Shell out to `direnv export json` after each chdir and apply the JSON
delta via os.Setenv/Unsetenv. direnv tracks the previous load in its own
DIRENV_DIFF env var, so the delta also unloads vars from the old repo
when entering one without a matching .envrc. If direnv isn't on PATH the
call is a no-op, so users who don't use direnv pay nothing and users who
do need no config to opt in. Any stderr direnv emits (loading messages,
"blocked .envrc" errors, etc.) goes to the command log.

The integration test puts a fake direnv on PATH and asserts that a value
it exports reaches a custom command after switching repos. Wiring this
up needed runner.go to support `{{actualPath}}` placeholders in
ExtraEnvVars, mirroring the existing support for ExtraCmdArgs, so the
test can prepend a fixture-relative directory to PATH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 09:05:01 +02:00
Stefan Haller 2601556189 Dedupe the recent-repos fallback in setupRepo
The for-loop here was a verbatim copy of openRecentRepo, so call that instead.
2026-06-04 09:05:01 +02:00
Stefan Haller 685dfc87a4 Cleanup: drop unneeded variable 2026-06-04 09:05:01 +02:00
Stefan Haller d86a49ba3f When RecordCurrentDirectory fails, only log the error
If we return the error here, we don't switch repos, but the chdir
happened already, so this would be an inconsistent state (a lot of
lazygit's code assumes that the current directory is always the worktree
root). Only log the error; failing to record the current directory is
not the end of the world.

Also, it is very unlikely to happen; RecordCurrentDirectory only writes
to a small file, and if this fails, then either there is filesystem
corruption of the disk is full, and in both cases the user likely has
much bigger problems.
2026-06-04 09:05:01 +02:00
Stefan Haller 009c8975be
Additions to AGENTS.md (#5667) 2026-06-03 09:21:42 +02:00
Stefan Haller cc6a0374e8 Additions to AGENTS.md 2026-06-03 09:18:31 +02:00
Stefan Haller 16cf2ff1a9
Fix the waiting status display for synchronous operations (#5659)
Commit 4f0393f97b caused a regression: for operations that use
WithWaitingStatusSync (examples are squashing fixups, moving commits up
or down, cherry-picking, creating fixup commits, and more), the waiting
status wouldn't show during the operation; however, it would show after
the operation was done, and then linger forever.

The cause: since 4f0393f97b, layout sizes the bottom line from the
actual content of the AppStatus view rather than from the status
manager. The async render path keeps the view in sync (it sets the
buffer on the first tick and clears it to "" when the status ends), but
the sync path used by WithWaitingStatusSync did not:

- It called ForceLayoutAndRedraw before writing anything to the view, so
layout saw an empty buffer and left no room; the status never appeared
during the operation.
- When the operation finished it just broke out of the loop, leaving the
last spinner frame in the buffer. Every subsequent layout kept reserving
room for that stale content, so the status stuck around forever.

Fix this by writing the status into the view before the initial layout,
and clearing it again when stopping.
2026-05-30 14:25:21 +02:00
Stefan Haller 101d7965ae Fix the waiting status display for synchronous operations
Commit 4f0393f97b caused a regression: for operations that use
WithWaitingStatusSync (examples are squashing fixups, moving commits up or down,
cherry-picking, creating fixup commits, and more), the waiting status wouldn't
show during the operation; however, it would show after the operation was done,
and then linger forever.

The cause: since 4f0393f97b, layout sizes the bottom line from the actual
content of the AppStatus view rather than from the status manager. The async
render path keeps the view in sync (it sets the buffer on the first tick and
clears it to "" when the status ends), but the sync path used by
WithWaitingStatusSync did not:

- It called ForceLayoutAndRedraw before writing anything to the view, so layout
  saw an empty buffer and left no room; the status never appeared during the
  operation.
- When the operation finished it just broke out of the loop, leaving the last
  spinner frame in the buffer. Every subsequent layout kept reserving room for
  that stale content, so the status stuck around forever.

Fix this by writing the status into the view before the initial layout, and
clearing it again when stopping.
2026-05-30 14:20:05 +02:00
Stefan Haller 64d244cfcb Refactor: extract private setAppStatusContent helper method 2026-05-30 13:59:17 +02:00
Stefan Haller f2788e475e
Add docs for how to add the default, non-pager diff to the list of pagers (#5656)
See #5328.
2026-05-28 19:46:47 +02:00
Stefan Haller 115b72d98b Add docs for how to add the default, non-pager diff to the list of pagers 2026-05-28 19:41:45 +02:00
Stefan Haller 371f57c76e Fix minor typos in README.md 2026-05-28 19:36:01 +02:00
Stefan Haller ff8f3d790e
Fix crash when keybindings are disabled that normally show in the status bar (#5655)
If a keybinding that we want to display in the options bar was set to
`<disabled>` by the user, in pre-0.62 versions we would still display
the command, but with no keybinding. This was arguably not very useful
before, but now it actually crashes because we would now try to display
the first key of the slice of configured keys (crash introduced in
3d18ee8f91). Fix the crash by not showing those commands at all.
2026-05-28 19:31:42 +02:00
Stefan Haller b3491f4e37 Cleanup: filter out empty keybindings earlier
This doesn't make a difference for the behavior, it just looks strange to
include the empty bindings first and then filter them out in the next statement.
2026-05-28 19:26:43 +02:00
Stefan Haller 76d0dc15ca Fix crash when keybindings are disabled that we want to show in the status bar
If a keybinding that we want to display in the options bar was set to <disabled>
by the user, in pre-0.62 versions we would still display the command, but with
no keybinding. This was arguably not very useful before, but now it actually
crashes because we would now try to display the first key of the slice of
configured keys (crash introduced in 3d18ee8f91). Fix the crash by not showing
those commands at all.
2026-05-28 19:26:43 +02:00
Stefan Haller 7299b224ef
Fix breaking changes note (#5646)
Ctrl+s used to be a separate binding confirmInEditor-alt, but now that
it was folded into the main confirmInEditor, we need to mention both
bindings here.

Also use the new syntax while we're at it.
2026-05-26 22:31:03 +02:00
Stefan Haller 5e326853dc Fix breaking changes note
Ctrl+s used to be a separate binding confirmInEditor-alt, but now that it was
folded into the main confirmInEditor, we need to mention both bindings here.

Also use the new syntax while we're at it.
2026-05-26 22:27:36 +02:00
Stefan Haller 8b4d043d52
Update docs and schema for release (#5645) 2026-05-26 22:16:14 +02:00
Stefan Haller 07d73fbbee Update docs and schema for release 2026-05-26 22:12:31 +02:00