Compare commits

...
61 Commits
Author SHA1 Message Date
Stefan Haller fbe2379fa5 Bump JamesIves/github-sponsors-readme-action from 1.6.0 to 1.6.1 (#5917)
Bumps
[JamesIves/github-sponsors-readme-action](https://github.com/jamesives/github-sponsors-readme-action)
from 1.6.0 to 1.6.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jamesives/github-sponsors-readme-action/releases">JamesIves/github-sponsors-readme-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.6.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Dependencies 🤖</h3>
<ul>
<li>chore(deps): bump the misc group across 1 directory with 3 updates
by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1034">JamesIves/github-sponsors-readme-action#1034</a></li>
<li>chore(deps-dev): bump <code>@​types/node</code> from 25.2.0 to
25.2.1 in the misc group by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1036">JamesIves/github-sponsors-readme-action#1036</a></li>
<li>chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1041">JamesIves/github-sponsors-readme-action#1041</a></li>
<li>chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1040">JamesIves/github-sponsors-readme-action#1040</a></li>
<li>chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/JamesIves/github-sponsors-readme-action/pull/1052">JamesIves/github-sponsors-readme-action#1052</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/JamesIves/github-sponsors-readme-action/compare/v1...v1.6.1">https://github.com/JamesIves/github-sponsors-readme-action/compare/v1...v1.6.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/02650b8cd445fc16dfef73195f9c406dce041623"><code>02650b8</code></a>
Merge remote-tracking branch 'origin/dev' into releases/v1</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/e958d3e9b7e1e704656add532e547cc86060d553"><code>e958d3e</code></a>
Deploy Production Code for Commit
d0e97b6c881bf5598173fe04a41bffac988b7667 🚀</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/c0481077d61a48f92a52ef4a7bcae9f2885fb6c8"><code>c048107</code></a>
Merge remote-tracking branch 'origin/dev' into releases/v1</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/8fd9552b1cef39636a3f6d11d8c674154655cfdd"><code>8fd9552</code></a>
fix: use a dedicated RELEASE_PAT for release creation</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/d0e97b6c881bf5598173fe04a41bffac988b7667"><code>d0e97b6</code></a>
Merge branch 'dev' of <a
href="https://github.com/JamesIves/github-sponsors-readme-act">https://github.com/JamesIves/github-sponsors-readme-act</a>...</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/6eb9fb19bdb29912a94bb93e6e691cc6ad4557c6"><code>6eb9fb1</code></a>
ci: run sponsors README update twice a week instead of daily</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/152fad67d2a569947ecc0378b76064af9300e494"><code>152fad6</code></a>
ci: run integration tests weekly instead of daily</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/7962d89b080bcdc4f980f5bba33bff803eba46b6"><code>7962d89</code></a>
chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (<a
href="https://redirect.github.com/jamesives/github-sponsors-readme-action/issues/1052">#1052</a>)</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/7b03cded5bf3927fbd58c83ad2cc4f662b23d657"><code>7b03cde</code></a>
fix: remove unsupported semver cooldown keys from the github-actions
ecosystem</li>
<li><a
href="https://github.com/JamesIves/github-sponsors-readme-action/commit/592de7d30b66a1ca22bcd92a1217aacabd2fb496"><code>592de7d</code></a>
security: add explicit permissions blocks to workflows</li>
<li>Additional commits viewable in <a
href="https://github.com/jamesives/github-sponsors-readme-action/compare/2fd9142e765f755780202122261dc85e78459405...02650b8cd445fc16dfef73195f9c406dce041623">compare
view</a></li>
</ul>
</details>
<br />
2026-08-12 19:45:58 +02:00
dependabot[bot] f77c1d37f1 Bump JamesIves/github-sponsors-readme-action from 1.6.0 to 1.6.1
Bumps [JamesIves/github-sponsors-readme-action](https://github.com/jamesives/github-sponsors-readme-action) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/jamesives/github-sponsors-readme-action/releases)
- [Commits](https://github.com/jamesives/github-sponsors-readme-action/compare/2fd9142e765f755780202122261dc85e78459405...02650b8cd445fc16dfef73195f9c406dce041623)

---
updated-dependencies:
- dependency-name: JamesIves/github-sponsors-readme-action
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 17:43:40 +00:00
Stefan Haller 27fcf4b729 Bump github.com/lucasb-eyer/go-colorful from 1.4.0 to 1.4.1 (#5916)
Bumps
[github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful)
from 1.4.0 to 1.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lucasb-eyer/go-colorful/releases">github.com/lucasb-eyer/go-colorful's
releases</a>.</em></p>
<blockquote>
<h2>v1.4.1</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: correct D50ToD65 to the CSS Color 4 matrix that inverts
D65ToD50 by <a
href="https://github.com/gaoflow"><code>@​gaoflow</code></a> in <a
href="https://redirect.github.com/lucasb-eyer/go-colorful/pull/85">lucasb-eyer/go-colorful#85</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/gaoflow"><code>@​gaoflow</code></a> made
their first contribution in <a
href="https://redirect.github.com/lucasb-eyer/go-colorful/pull/85">lucasb-eyer/go-colorful#85</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1">https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/lucasb-eyer/go-colorful/blob/master/CHANGELOG.md">github.com/lucasb-eyer/go-colorful's
changelog</a>.</em></p>
<blockquote>
<h2>[1.4.1] - 2026-08-02</h2>
<h3>Fixed</h3>
<ul>
<li>Corrected <code>D50ToD65</code> to use the CSS Color 4 matrix
inverse of <code>D65ToD50</code> (<a
href="https://redirect.github.com/lucasb-eyer/go-colorful/issues/85">#85</a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lucasb-eyer/go-colorful/commit/315b48282c63bac7b48ba128d0c87b7f827b2285"><code>315b482</code></a>
ready for v1.4.1</li>
<li><a
href="https://github.com/lucasb-eyer/go-colorful/commit/e9123175c008141525fe0eee08a328987b66f650"><code>e912317</code></a>
fix: correct D50ToD65 to the CSS Color 4 matrix that inverts
D65ToD50</li>
<li>See full diff in <a
href="https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1">compare
view</a></li>
</ul>
</details>
<br />
2026-08-12 19:41:33 +02:00
dependabot[bot] 93b8f343bb Bump github.com/lucasb-eyer/go-colorful from 1.4.0 to 1.4.1
Bumps [github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/lucasb-eyer/go-colorful/releases)
- [Changelog](https://github.com/lucasb-eyer/go-colorful/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lucasb-eyer/go-colorful/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: github.com/lucasb-eyer/go-colorful
  dependency-version: 1.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 17:38:40 +00:00
Stefan Haller c18a0c6680 Fix pull requests silently disappearing until lazygit is restarted (#5921)
A lazygit instance that has been open for a while sometimes stops
showing pull requests, and keeps not showing them until you quit and
restart it. The cause is that we resolve the GitHub token once per
process and then keep using that stale answer.

We get the token from go-gh, which reads gh's `hosts.yml` on the first
call and caches it for the lifetime of the process. gh rewrites that
file whenever the active account changes, and keeps the active account's
token either in the file or in the system keyring, depending on the
account. Once the file changes under us, our snapshot no longer
describes reality: either we keep sending a token for an account that is
no longer active, or — if the snapshot was taken while a keyring-backed
account was active — we find no token at all, drop the remote, and show
no pull requests. Nothing is reported to the user, so it looks like PR
fetching is simply broken.

Switching accounts with `gh auth switch` is the easiest way to trigger
this, but it isn't limited to multi-account setups: a single account
hits the same thing whenever its token is rotated, or moved between the
keyring and `hosts.yml` by a fresh `gh auth login`.

So ask gh itself for the token on every refresh, with `gh auth token
--hostname <host>`, which resolves it afresh from whichever of the
environment, the keyring, or the config file currently holds it. Not
passing `--secure-storage` (which go-gh does internally) also means it
stops mattering which of the two the active account uses. go-gh's lookup
stays as a fallback for setups without the gh binary, where it still
picks up `GH_TOKEN` and friends; when gh is present it checks those
variables itself.

To reproduce on master, with two gh accounts, one with its token in the
keyring and one in `hosts.yml`: start lazygit while the keyring-backed
account is active, then run `gh auth switch` to the other one. The pull
request information disappears on the next refresh and doesn't come back
until lazygit is restarted. Running `gh auth token --secure-storage
--hostname github.com` by hand at that point prints `no oauth token
found for github.com`.
2026-08-12 19:30:37 +02:00
Stefan HallerandClaude Opus 5 c8bc1928f2 Get the GitHub token from gh instead of resolving it in-process
go-gh reads gh's config file once per process and answers from that
snapshot for the rest of the process's life. gh rewrites the file
whenever the active account changes, and stores the active account's
token either in it or in the system keyring, depending on the account.
A lazygit that has been running for a while therefore consults a
snapshot that no longer describes reality: it either keeps using a
token for an account that is no longer active, or, when the snapshot
was taken while a keyring-backed account was active, finds no token at
all and silently stops showing pull requests until it is restarted.

Asking gh resolves the token afresh on every refresh, from whichever of
the environment, the keyring or the config file currently holds it.
go-gh's lookup stays behind as a fallback for setups without the gh
binary, where it still picks up GH_TOKEN and friends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:13:06 +02:00
Stefan Haller 4b22b844e7 Fix hang on quit when confirmOnQuit is true (#5919)
When confirmOnQuit is true, quitting would sometimes hang for three
seconds and then print "cannot kill child process". Concretely, this
happened whenever the Files panel was focused but there were no changed
files (the main view shows "No changed files").

This is a regression in 0.64.0, it worked before.

Fixes #5918.
2026-08-12 19:12:16 +02:00
Stefan HallerandClaude Opus 5 ec577f1afa Give up waiting for the UI thread once the main loop has exited
Quitting with confirmOnQuit set hung for three seconds and printed
"cannot kill child process", but only with a clean working tree. Closing
the confirmation pops the context before running its handler, so the
files panel is re-focused and re-renders the main view, and only then
does the handler return ErrQuit. With no changed files that render is a
string task, whose whole body is one hop to the UI thread — a hop that
is never served, because the handler's ErrQuit has meanwhile brought the
main loop down. The task can't finish, so the ViewBufferManager.Close
that follows waits for it until it times out. (With changed files it's a
command task instead, and every blocking point in one of those selects
on the stop channel, so Close gets through.)

A wait for the UI thread now ends when the loop does. That also covers
the command task's own hops, which are stopped only in between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan HallerandClaude Opus 5 70427c8ff5 Add a test for waiting on the UI thread after the loop has exited
Nothing dequeues user events once MainLoop has returned, so a worker
blocked in OnUIThreadAndWait is blocked for good. The assertion records
that; the next commit makes the wait give up instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan HallerandClaude Opus 5 2f06724b80 Make RefreshHelper pay attention to the error returned from OnUIThreadAndWait
Right now the function always returns nil, but this will change later in
this branch, so handle errors properly. Without that, the first capture
that assigns env.git would not run, leave env.git nil, and subsequent
code would crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:03:26 +02:00
Stefan HallerandClaude Opus 5 f9b790a1f9 Let OnUIThreadAndWait's error be about the wait, not about f
Every caller passes an f that unconditionally returns nil, so f's error
return has never carried anything: the value is dead weight, and it
occupies the one channel the wait itself needs to report that it couldn't
run f at all. Drop it, so that the error the wait returns can only ever
mean that.

Work that can fail hands its error back through a captured variable, the
way the background fetch already hands back four values, which keeps the
two outcomes distinguishable at a call site that has both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:04:53 +02:00
Stefan Haller ddceff6962 Bump mheap/github-action-required-labels from 5.5.2 to 5.6.0 (#5769)
Bumps
[mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels)
from 5.5.2 to 5.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mheap/github-action-required-labels/releases">mheap/github-action-required-labels's
releases</a>.</em></p>
<blockquote>
<h2>v5.6.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump GitHub client to v7 by <a
href="https://github.com/VincentLanglet"><code>@​VincentLanglet</code></a>
in <a
href="https://redirect.github.com/mheap/github-action-required-labels/pull/96">mheap/github-action-required-labels#96</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/VincentLanglet"><code>@​VincentLanglet</code></a>
made their first contribution in <a
href="https://redirect.github.com/mheap/github-action-required-labels/pull/96">mheap/github-action-required-labels#96</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/mheap/github-action-required-labels/compare/v5.5.2...v5.6.0">https://github.com/mheap/github-action-required-labels/compare/v5.5.2...v5.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/mheap/github-action-required-labels/commit/23e10fde7e062233401931a0eece796cd9bf3177"><code>23e10fd</code></a>
Automatic compilation</li>
<li><a
href="https://github.com/mheap/github-action-required-labels/commit/6e4081ebcbed471862a3ddcbc8b5f6ce99b4263a"><code>6e4081e</code></a>
Replace nock with undici in tests + bump GH client to v7 (<a
href="https://redirect.github.com/mheap/github-action-required-labels/issues/96">#96</a>)</li>
<li>See full diff in <a
href="https://github.com/mheap/github-action-required-labels/compare/0ac283b4e65c1fb28ce6079dea5546ceca98ccbe...23e10fde7e062233401931a0eece796cd9bf3177">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:59:43 +02:00
dependabot[bot] f416a4ba6a Bump mheap/github-action-required-labels from 5.5.2 to 5.6.0
Bumps [mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels) from 5.5.2 to 5.6.0.
- [Release notes](https://github.com/mheap/github-action-required-labels/releases)
- [Commits](https://github.com/mheap/github-action-required-labels/compare/0ac283b4e65c1fb28ce6079dea5546ceca98ccbe...23e10fde7e062233401931a0eece796cd9bf3177)

---
updated-dependencies:
- dependency-name: mheap/github-action-required-labels
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:57:21 +00:00
Stefan Haller 2399cac0db Bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14 (#5813)
Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji)
from 2.2.13 to 2.2.14.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/kyokomi/emoji/releases">github.com/kyokomi/emoji/v2's
releases</a>.</em></p>
<blockquote>
<h2>v2.2.14</h2>
<h2>What's Changed</h2>
<h3>Emoji data</h3>
<ul>
<li>Regenerated <code>emoji_codemap.go</code> with Unicode Emoji 16.0 /
17.0 additions — 18 new emoji such as <code>🫆</code>,
<code>:orca:</code>, <code>:treasure_chest:</code>,
<code>:ballet_dancer:</code> (no removals)</li>
</ul>
<h3>Behavior change</h3>
<ul>
<li><code>NormalizeShortCode</code> now consistently returns the
lowercase alias when same-length aliases differ only in case. Affects 26
entries (e.g. <code>:Aries:</code> → <code>♈</code>,
<code>:OK_hand:</code> → <code>👌</code>, <code>:ZZZ:</code> →
<code>💤</code>, <code>:T-Rex:</code> → <code>🦖</code>)</li>
</ul>
<h3>Maintenance</h3>
<ul>
<li>go.mod: <code>go 1.14</code> → <code>go 1.21</code> (cmd module:
<code>go 1.12</code> → <code>go 1.25</code>, goquery v1.5.1 →
v1.12.0)</li>
<li>CI: pin Go via <code>go-version-file</code>, golangci-lint-action v6
→ v8, verify cmd module</li>
<li>Added dependabot for github-actions and gomod</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14">https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/kyokomi/emoji/commit/a659fe56a640109bc44bfecdd0a9641642971f27"><code>a659fe5</code></a>
Maintenance: update emoji data, modernize Go/CI, add dependabot (<a
href="https://redirect.github.com/kyokomi/emoji/issues/65">#65</a>)</li>
<li><a
href="https://github.com/kyokomi/emoji/commit/eb108489069ff2953e6bb4fe471784e664563644"><code>eb10848</code></a>
Bump GitHub workflow actions (<a
href="https://redirect.github.com/kyokomi/emoji/issues/64">#64</a>)</li>
<li>See full diff in <a
href="https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:55:18 +02:00
dependabot[bot] d6cf948dca Bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14
Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji) from 2.2.13 to 2.2.14.
- [Release notes](https://github.com/kyokomi/emoji/releases)
- [Commits](https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14)

---
updated-dependencies:
- dependency-name: github.com/kyokomi/emoji/v2
  dependency-version: 2.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:52:34 +00:00
Stefan Haller 4b257646ad Bump actions/setup-go from 6 to 7 (#5843)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to
7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/763">actions/setup-go#763</a></li>
<li>chore(deps): bump <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v7.0.0">https://github.com/actions/setup-go/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<h3>Dependency update</h3>
<ul>
<li>Upgrade actions dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in
<a
href="https://redirect.github.com/actions/setup-go/pull/744">actions/setup-go#744</a></li>
<li>Upgrade <code>@​types/node</code> and typescript-eslint dependencies
to resolve npm audit findings by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/755">actions/setup-go#755</a></li>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/758">actions/setup-go#758</a></li>
<li>Upgrade version to 6.5.0 in package.json and package-lock.json by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/762">actions/setup-go#762</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/744">actions/setup-go#744</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/758">actions/setup-go#758</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.5.0">https://github.com/actions/setup-go/compare/v6...v6.5.0</a></p>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Enhancement</h3>
<ul>
<li>Add go-download-base-url input for custom Go distributions by <a
href="https://github.com/gdams"><code>@​gdams</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/721">actions/setup-go#721</a></li>
</ul>
<h3>Dependency update</h3>
<ul>
<li>Upgrade minimatch from 3.1.2 to 3.1.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/727">actions/setup-go#727</a></li>
</ul>
<h3>Documentation update</h3>
<ul>
<li>Rearrange README.md, add advanced-usage.md by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/724">actions/setup-go#724</a></li>
<li>Fix Microsoft build of Go link by <a
href="https://github.com/gdams"><code>@​gdams</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/734">actions/setup-go#734</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/gdams"><code>@​gdams</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/721">actions/setup-go#721</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.4.0">https://github.com/actions/setup-go/compare/v6...v6.4.0</a></p>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update default Go module caching to use go.mod by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/705">actions/setup-go#705</a></li>
<li>Fix golang download url to go.dev by <a
href="https://github.com/178inaba"><code>@​178inaba</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/469">actions/setup-go#469</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.3.0">https://github.com/actions/setup-go/compare/v6...v6.3.0</a></p>
<h2>v6.2.0</h2>
<h2>What's Changed</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-go/commit/b7ad1dad31e06c5925ef5d2fc7ad053ef454303e"><code>b7ad1da</code></a>
chore(deps): bump <code>@​actions/cache</code> to 6.2.0 (<a
href="https://redirect.github.com/actions/setup-go/issues/771">#771</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/0778a10ce47b5d450cf60fb94fafad4330008a35"><code>0778a10</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-go/issues/763">#763</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />
2026-08-08 17:50:54 +02:00
dependabot[bot] d3a2c87293 Bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 15:47:46 +00:00
Stefan Haller f162dc5aec Honor the conflict-marker-size gitattribute (#5902)
If the `conflict-marker-size` git attribute is used to set the marker
size to a non-default value (!= 7), lazygit's handling of conflicted
files was totally broken. Stopping at a commit with conflicts in a
rebase would show the `UU` files for a moment, and then, a few seconds
later, would stage all conflicted files and offer to continue the rebase
(with the conflicts baked into the resulting commits if you confirmed).
Even if you cancelled the continue prompt, it wasn't possible to use
git's conflict panel to resolve the conflicts; it would only show the
regular diff for those files, not its conflicts editor.

Fix this by querying the `conflict-marker-size` git attribute for all
conflicting files and use that to match the conflict markers.

Fixes #4367.
2026-08-08 12:58:14 +02:00
Stefan Haller d0078bf05c Recognize conflict markers that have no label
Git only writes the space after a marker when there is a label to write
after it, and the label can be empty: `git checkout -m` with the diff3
conflict style, for instance, has no name for the common ancestor, so it
writes a bare "|||||||" line.
2026-08-08 12:43:42 +02:00
Stefan Haller 5481436d8c Honor the conflict-marker-size gitattribute
Ask git for the attribute of every conflicted file whenever we load the
file status, so that we recognize the markers it actually wrote. Files
that are set up this way are precisely the ones whose regular content
tends to contain marker-looking lines, so matching a run of at least
seven characters instead is not an option: we'd take the file's own
content for markers and then never consider its conflicts resolved.

One `git check-attr` call covers all conflicted files at once; asking per
file would take seconds when hundreds of files are conflicted, and it
would hurt worst on Windows, where spawning a process is expensive.
Because the lookup rides along with the file status, it costs nothing
when there are no conflicts, and editing .gitattributes during a merge
takes effect on the next refresh.
2026-08-08 12:43:42 +02:00
Stefan Haller c3450f9406 Add tests demonstrating that we ignore the conflict-marker-size gitattribute
When a file's conflict markers aren't seven characters long we don't
recognize them at all. Two things go wrong: we consider the file's
conflicts resolved, so we stage it and offer to continue the merge a
moment after stopping at it; and pressing enter on it shows its diff
instead of the merge conflicts view, leaving no way to resolve it in
lazygit.
2026-08-08 12:43:42 +02:00
Stefan Haller bc9fafff02 Make the conflict marker size a parameter of our marker matching
Git doesn't always write conflict markers of seven characters: the
conflict-marker-size gitattribute overrides that per file, and it is set
for good reasons — for file types whose regular content tends to contain
marker-looking lines, such as documentation about merging, or test
scripts. We hard-code seven characters everywhere we look for markers,
so none of that works.

Prepare for honoring the attribute by threading the marker size through
everything that recognizes a marker, carried on the file model. Nothing
fills it in yet, so we still use git's default size of seven everywhere,
and matching is unchanged: a marker consists of exactly that many marker
characters, and all but the "=======" one are followed by a space and a
label.
2026-08-08 12:43:42 +02:00
Stefan Haller 5dec89abfe Update the UI after stash operations in a single frame (#5905)
This fixes a regression in 0.64.0: before that version, creating or
popping a stash would happen synchronously on the UI thread (including
the refresh), blocking the UI until everything changed, including the
panel focus. Blocking the UI was not nice of course, but at least the UI
update was clean. With 0.64.0 this changed to a background refresh, so
that the update to the two panels and the focus change all happened out
of sync, which looks rather ugly. Fix this by using Refresh's mechanism
to batch UI updates, and switch the panel focus in the Refresh's Then so
that it updates at the same time.

While we're at it, use a waiting status spinner for these operations;
they are usually fast when only few files are involved, but when
stashing a large number of files in a larger repo it can be noticeable,
and it looks ugly if the confirmation prompt stays on the screen while
it is running.
2026-08-08 12:43:29 +02:00
Stefan HallerandClaude Opus 5 4c39b0b903 Run the stash operations with a waiting status
Creating and applying a stash both touch every changed file, so in a
large repo they can take long enough to be noticeable — and running them
on the UI thread meant the confirmation popup stayed on screen, frozen,
for the whole operation. Run them on a worker instead, with a spinner,
and keep blocking input for their duration so that the type-ahead
guarantee the refresh used to provide still holds.

Dropping stays on the UI thread: it only rewrites the stash reflog, so
it's fast no matter how big the stashes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 ed22322ec8 Collapse the stash range selection from the refresh's Then
Collapsing the range before kicking off the refresh paints the new
selection against the list as it was before the drop, so for a frame the
entries that were just dropped are still on screen (and, with
gui.shrinkSidePanelsToContent, the panel is still at its old size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 6c567d1eb6 Switch to the files panel from the post-stash refresh's Then
Pushing the files context right after kicking off the refresh moves the
focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a
frame before the refreshed stash and files lists arrive. Doing it from
Then puts it in the same frame as the data it belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 544f3b834b Apply the panel updates after stash operations in a single frame
Stashing and popping change both the stash list and the files list.
With each scope updating the UI as soon as its own refresh is done, the
two panels visibly change at different times; with
gui.shrinkSidePanelsToContent that also means their sizes change at
different times than their contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller da7ca77c9d AGENTS.md additions 2026-08-08 12:26:18 +02:00
Stefan Haller bf5829af3f Fix several problems with repos whose git dir lives outside the working tree (#5910)
Lazygit assumes a repo can be found again from its working directory: it
chdirs there and lets git rediscover the git dir from `<worktree>/.git`.
That holds for an ordinary repo and breaks for every setup where the git
dir lives somewhere else, which is where these bugs come from. Opening
such a repo worked at all only when `--git-dir` happened to leave
`GIT_DIR` in the environment for every command to inherit — which is
also why entering a submodule, which has to clear it, broke the way back
out.

Three reported problems:

- **`core.worktree` (#5895).** A repo whose work tree is elsewhere
panicked on startup with `fatal: not a git repository`: we chdir'd into
a work tree with no `.git` in it and every command after that was lost.
We now work out at startup whether git can find the repo from its work
tree, and when it can't we put `GIT_DIR`/`GIT_WORK_TREE` on every
command the repo's command builder produces — as well as in the process
environment, for subprocesses that don't come through the builder.
Nothing is set for the repos git can find on its own, which is nearly
all of them.

- **Escaping a submodule of a dotfile repo (#1118).** The repo-path
stack we push the superproject onto only held its path, and for a repo
opened with `--git-dir`/`--work-tree` the path leads nowhere. Escaping
failed with `not a git repository`, or, if some unrelated repo happened
to lie above the work tree, quietly switched to that one instead. The
stack now carries the environment as well, taken from the repo paths
rather than from the process env, so it also covers a repo whose
location lazygit worked out itself.

- **Opening a directory that holds a bare repo (#5469, #5681).** `git
rev-parse --show-toplevel` is fatal when there's no work tree, so we
never got an answer at all for a bare repo: `IsBareRepo()` could never
come out true, and lazygit either died with a stack trace or decided we
weren't in a repository. We now ask again without `--show-toplevel` when
the first query fails, and the existing "open most recent repo?" prompt
does its job.

Some related things that turned up on the way:

- **A submodule no longer looks like a linked worktree.** `git worktree
list` reports the main worktree as the common git dir with a trailing
`/.git` removed, which is not the working tree when the git dir doesn't
live inside it. Comparing that against the working tree path matched
nothing, so inside a submodule the status bar claimed we were in a
linked worktree named after the submodule, the worktrees panel listed it
as not current, and its branch got a "checked out elsewhere" marker.
Worktrees are now identified by their git dir, which names them
unambiguously.

- **Commands aimed at another repo no longer resolve against ours.**
With `GIT_DIR` set, `git -C mysub log -1` reports the *superproject's*
commit, silently. So opening lazygit with `--git-dir`/`--work-tree`
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree.

- **Starting lazygit in a repo's `.git` dir opens the repo.** It used to
tell you that you were in a bare repo, which you weren't — the work tree
was one directory up. git's own convention is that a git dir called
`.git` belongs to the directory holding it, so we look there. (A linked
worktree's or a submodule's git dir isn't called `.git`, and nothing we
look at says where their work tree is, so those still get the prompt.)

- **`RepoPath()`** is documented to be the work tree when we're in the
main worktree, but was derived from the git dir's location, which is
only the same thing when the git dir is inside the work tree. This fixes
the repo name shown in the status panel for split setups.

Fixes #1118
Fixes #5469
Fixes #5681
Fixes #5736
Fixes #5895
2026-08-08 12:20:44 +02:00
Stefan HallerandClaude Opus 5 f141fcc570 Open the repo when lazygit is started in its .git dir
Running lazygit in a .git dir got you told you were in a bare repo,
which you weren't: the worktree was sitting right there, one directory
up. git's own convention is that a git dir called .git belongs to the
directory holding it — that's how `git worktree list` names the main
worktree — so ask that directory, and if it is a worktree, open the repo
we were really being asked about.

The git dirs that aren't called .git keep the answer they had. A linked
worktree's and a submodule's do have a worktree, but nothing we look at
says where, so we would be guessing; a bare repo's has none to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 06b421ad0c Remember how to get back to a repo we entered a submodule from
Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they
say where the superproject is. But the stack we push the superproject
onto so that escape brings us back only held its path, and for a repo
opened with --git-dir/--work-tree the path leads nowhere — git can't
find a repo there. Escaping out of a submodule of a dotfile repo failed
with "not a git repository", or, if some unrelated repo happened to lie
above the work tree, quietly switched to that one instead.

Push the environment onto the stack along with the path, taken from the
repo paths rather than from the process env, so that it also covers a
repo we worked the location out for ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 9b1078a2ca Make StringStack generic
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 d19af37ee7 Tell git where the repo is when it can't find it itself
git finds a repo by looking for a .git in the directory a command runs
in. Lazygit runs its commands in the work tree, so that normally works —
but not when the git dir lives somewhere else entirely, which is what
core.worktree and --work-tree are for. Lazygit chdir'd into such a work
tree and then ran commands that couldn't see any repo from there, so
opening a repo with core.worktree set panicked on startup. It only
worked with --git-dir because that leaves GIT_DIR in the environment for
every command to inherit.

Work out at startup whether git can find the repo from its work tree,
and when it can't, put GIT_DIR and GIT_WORK_TREE on every command the
repo's builder produces. As with the working directory the builder pins
(527124d0e0), these also go into the process env — subprocesses don't
come through the builder — but the commands don't read them from there,
because the process env belongs to whichever repo we have switched to
since.

Working out whether git can find the repo means asking git, rather than
reading the .git file, whose contents can spell the same directory
differently than git does. The extra query is skipped for a repo whose
git dir is simply its .git directory, which is nearly all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 34d41b5d51 Don't let our repo answer for a different one
GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command
we run inherits them — including the ones we point at a submodule or
another worktree. git resolves those against our repo instead, and says
nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the
superproject's commit. So opening lazygit with --git-dir/--work-tree
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree; the worktree list came back
claiming every worktree shared our git dir.

Drop the two variables from the commands that address another repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 616d75a1fa Run Reset in the parent module the way the other commands do
Reset told git to change directory with -C while runInParentModule does
it by setting the command's working directory, but they were computing
the same directory for the same reason. Use the helper, so that there is
one place that knows what running in a nested submodule's parent means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 3d80e466ce Say why runInParentModule can name a relative directory
Its working directory resolves against the process rather than against
the repo the command builder pins commands to, which is only safe
because nothing but foreground commands come through here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 0ce248d1bf Recognize a repo that has no work tree instead of bailing out
git makes `rev-parse --show-toplevel` fatal when there's no work tree,
so asking for it together with everything else meant we never got an
answer at all for a bare repo: GetRepoPaths returned an error, nobody
ever saw IsBareRepo() == true, and lazygit either died with a stack
trace or decided we weren't in a repository. That's what you got for
opening it in a directory holding a bare repo and a .git file pointing
at it, which is a normal way to keep a repo and its worktrees together.

Ask again without --show-toplevel when the first query fails: the other
queries work fine without a work tree, so if they now succeed we know
we're in a bare repo, and the existing prompt offering to open a recent
repo does its job. If they fail too we're not in a repo at all, and the
first error already says so.

--is-bare-repository is gone from the query: a work tree implies
core.bare is false, so it could only ever come back false there, and
what matters to us is whether there is a work tree to show, which is
what we now go by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e10a2f6a27 Stop promising bare repo support
"does not yet support" reads as a promise that it will, but it's quite
likely that it never will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 ca6c0500e6 Don't clear gui.git when we fail to open a repo
onNewRepo also runs when switching repos, and a failure there leaves us
in the repo we came from — with a nil GitCommand, which nothing else is
prepared for. Only assign once we have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e17ed2484c Use the work tree as the repo path when it is the main worktree
RepoPath() is meant to be the same as WorktreePath() when we're in the
main worktree, but we derived it from the git dir's location instead.
That is only the same thing when the git dir lives inside the work tree.
With core.worktree, --work-tree, or a .git file pointing at a repo dir
that isn't called .git, it lands on a directory that isn't a worktree at
all, and the repo name we show follows it there.

A worktree that has the repo's common git dir to itself is the main
worktree, so use its path. That subsumes the submodule case, whose git
dir lives under the superproject's .git/modules but is still the
submodule's own common dir; --show-superproject-working-tree is now only
needed for a linked worktree of a submodule.

The existing bare repo test asserted a git output that can't occur (a
work tree and --is-bare-repository=true at once), but the rest of it is
the shape of a repo opened with --git-dir/--work-tree, where the new
repo path is the correct one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 180039e78c Add a repo paths test for a repo with a separate work tree
When the work tree lives somewhere else entirely — set up with
core.worktree or --work-tree — we're still in the main worktree, so
RepoPath() should be the work tree, as its own doc comment says. Instead
we derive it from the git dir's location, which lands somewhere that
isn't a worktree at all, and the repo name follows it.

The ACTUAL lines are indented as they will be once the EXPECTED ones
replace them, rather than as gofumpt wants them while the comment
markers are still splitting the struct's alignment. That leaves this one
file not gofumpt-clean until the next commit, in exchange for a diff
there that shows only the lines that actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 d2d5bdc2bc Identify the current and main worktree by git dir, not by path
`git worktree list` reports the main worktree as the common git dir with
a trailing "/.git" removed, which equals the working tree only when the
git dir sits inside it. In a submodule, a bare repo, or a repo using
core.worktree it doesn't, so comparing the reported path against the
working tree path matches nothing: no worktree is recognized as current
or as main. Most visibly, inside a submodule lazygit claimed we were in
a linked worktree named after the submodule, and offered to remove that
"worktree".

Comparing git dirs identifies a worktree unambiguously, so use that.
A worktree whose directory is gone has no git dir to compare, and there
we still have nothing better than its path.

The submodule tests were asserting the linked-worktree suffix in the
status view; it is gone now, and the repo name still says which
submodule we're in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e1b8ef048a Add a worktree loader test for being in a submodule
A submodule's git dir doesn't live inside its working tree, and `git
worktree list` reports it by its git dir. Lazygit compares that against
the working tree path, so it recognizes neither the current nor the main
worktree, and the UI ends up claiming we're in a linked worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 7cbd93f945 Give the worktree loader tests their repos' git dirs
The scenarios describe their repo by its paths but leave the git dirs
empty, which no repo has. Unused for now; the loader is about to want
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller d1014aecf3 Simplify the run_integration_tests.sh script (#5908)
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:52:20 +02:00
Stefan Haller e1391298aa Simplify the run_integration_tests.sh script
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:31:58 +02:00
Stefan Haller 813de837ef update Nix flake dependencies (#5894)
### PR Description

Refresh the Nix flake inputs - see #5882.

Direct input revisions:

- `flake-parts`: `758cf7296b` → `427bf4bd94`
- `nixpkgs`: `c9b6fb7985` → `e72e4f2994`
- `treefmt-nix`: `5eda4ee812` → `d1187f8bc7`
- `systems`: unchanged at `da67096a3b`
- `flake-compat`: unchanged at `ff81ac966b`; its immutable URL metadata
  was normalized by the lock refresh

The refresh also updates the transitive `nixpkgs-lib` and
`treefmt-nix/nixpkgs` inputs.

The newer nixpkgs revision deprecates `pkgs.nixfmt-rfc-style` because it
now aliases `pkgs.nixfmt`. Uses the current attribute directly,
preserving
formatter behavior and removing the two evaluation warnings.

### Testing

- `nix flake check`
- `nix develop --ignore-environment -c just format` with a writable
  `GOCACHE`
- `nix develop --ignore-environment -c just build` with a writable
  `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just unit-test` with
  a writable `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just lint` with
  managed proxy variables and task-local caches
- `nix-shell --run 'just --version'`
- `git diff --check`

### Please check if the PR fulfills these requirements

- [x] Cheatsheets are up-to-date — no integration-test or keybinding
changes
- [x] Code has been formatted
- [x] Tests have been added/updated — no test changes needed; unit suite
passes
- [x] Text is internationalised — no user-facing strings changed
- [x] UserConfig hot reload is unaffected — no UserConfig changes
- [x] Docs have been updated if necessary — no documentation changes
needed
- [x] You've read through your own file changes for silly mistakes etc

<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view',
and make sure the title
is suitable to be included as a bullet point in release notes (i.e.
phrased from a user's point
of view).
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for
examples
-->
2026-08-06 16:51:55 +02:00
Stefan Haller 71ff5fd827 Update the versions pinned for the Nix build
flake.lock pins the exact revision of everything the Nix build pulls in
(the package set it takes git, Go and the formatters from, plus a few
helper libraries), much like go.sum does for Go modules. Nothing
refreshes it automatically and no CI job exercises it, so the pins had
quietly drifted the better part of a year behind.

The flake-compat entry also grows a "?rev=...&revCount=..." suffix on
its download URL. That is not a version change: the server hosting the
archive advertises those two values as part of the canonical URL now,
and they only restate the rev and revCount that the same entry already
lists as separate fields. The archive fetched is byte-for-byte identical
either way.
2026-08-06 16:49:23 +02:00
Stefan Haller a9bb960e5c Use nixfmt's current package name in the flake
The nixpkgs package set renamed the Nix code formatter: what used to be
called nixfmt-rfc-style is now simply nixfmt, and the old name lives on
only as an alias. Both names resolve to the identical program, so this
doesn't change how anything gets formatted.

The rename comes first because the dependency update in the next commit
makes that alias start printing a deprecation warning, so doing it in
this order means no commit in the history evaluates with one.
2026-08-06 16:49:23 +02:00
Stefan Haller 900c3e3c45 Fix race in "Stash staged changes" on git versions before 2.32.0 (#5903)
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left git
apply with an empty patch, so it failed with "unrecognized input", the
following `git stash drop` never ran, and the user was left with a stray
stash entry. This turned up as a flaky stash/stash_staged on the git
2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.
2026-08-06 16:14:08 +02:00
Stefan HallerandClaude Opus 5 d2a1a4f2a2 Start all commands of a pipeline before waiting for any of them
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left
git apply with an empty patch, so it failed with "unrecognized input",
the following `git stash drop` never ran, and the user was left with a
stray stash entry. This turned up as a flaky stash/stash_staged on the
git 2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:34:47 +02:00
Stefan Haller bd76666d97 Fix transitions of entering and exiting filtering mode (e.g. by path or author) (#5897)
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

This is a regression in 0.64.0 (more specifically, from #5790): before
that we stopped blocking the UI thread on refreshes, the reload happened
before any of it, so the two always agreed; the price was a frozen UI
for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed
while it runs arrive after the change rather than acting on a list that
is about to be replaced.
2026-08-05 17:49:00 +02:00
Stefan Haller 1b901c7187 Scroll the selection into view after a filtering mode change
The commit list a filtering mode change leaves behind has nothing to do
with the one that was showing, so the scroll position it inherits says
nothing about where the selection ended up, and the selection can land
anywhere off screen. PostRefreshUpdate only moves the cursor within the
existing scroll position, so ask for the scroll separately, the way the
commits refresh does when it moves the selection itself.

Exiting filtering mode looked like it worked, but only by accident: the
commits refresh recognizes the commit that was selected before it ran,
selects it again at its new index, and scrolls because the index moved.
That does nothing for the case where the commit is gone from the list, or
for entering filtering mode, where we select the first commit ourselves.
2026-08-05 17:29:20 +02:00
Stefan Haller 7e6d5ff7c1 Don't show an unfiltered list as if it were filtered
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

Before we stopped blocking the UI thread on refreshes, the reload
happened before any of it, so the two always agreed; the price was a
frozen UI for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed while
it runs arrive after the change rather than acting on a list that is
about to be replaced.
2026-08-05 17:29:20 +02:00
Stefan Haller b30c734513 Handle entering and leaving filtering mode in one place
Setting a filter and clearing it are the same transition in opposite
directions: mutate the mode, bring the screen mode in line with it,
reload the views that depend on the filter, and put the selection
somewhere sensible in the reloaded commit list. They were implemented
twice, once in the filtering menu and once in ModeHelper, which is how
the two came to repaint the commit list in different ways.

Derive the screen mode and the panel switch from whether a filter is
active after the change, so both directions fall out of the same code,
and give ModeHelper the entry points for both. The filtering menu is
left with nothing but the menu.
2026-08-05 17:29:20 +02:00
Stefan Haller e54cb4bf42 Decouple hiding the working tree state from blocking input
Blocking keyboard input and hiding the working tree state mode are two
separate concerns; they were fused into one helper because every caller
so far wanted both. A caller that blocks input for something other than a
rebase would then hide the "Rebasing" indicator for the duration of its
operation, which has nothing to do with it.

Make it an explicit option instead, so blocking input on its own doesn't
imply anything about the modes on display.
2026-08-05 17:29:20 +02:00
Stefan Haller f4968f6839 Rename suppressRebasingMode to suppressWorkingTreeStateMode
The mode it suppresses is active for any working tree state, not just a
rebase: merging, cherry-picking and reverting show through the same
indicator. Name it after what it hides.
2026-08-05 17:29:20 +02:00
Stefan Haller f8b7bab1ab Decide the commit graph from the loaded list, not the filtering mode
Whether a graph can be drawn was read from the filtering mode, while the
graph itself is drawn over the commit list in the model. Those two only
agree once the list has been reloaded for the new mode, and a filtering
mode change reloads the list in the background, so in between we can be
asked to draw a graph over a list the graph makes no sense for.

That is not just cosmetic. Commits in a filtered list are almost never
each other's parents, so no pipe ever terminates: the pipe set grows by
one per row and every continuing pipe rescans it, which is cubic in the
length of the list. Escaping out of filtering mode with a filtered list
of 13000 commits — as you get once the 300 commit limit has been lifted,
which happens for good as soon as the selection passes COMMIT_THRESHOLD
— wedges the UI thread for around twenty minutes.

Record whether the list was loaded with a filter, right where the list
itself is stored, and decide from that. The graph now also stays up while
the pre-change list is still on display, rather than vanishing a moment
before the list it belongs to.
2026-08-05 17:29:20 +02:00
Stefan Haller 8996bd68b9 Don't let integration tests race a background git repack (#5898)
This fixes spurious test failures caused by git's auto maintenance
running concurrently with a test's fixture setup; see the first commit's
message for details.

In addition, we change the test harness so that such a failure in a
setup method doesn't take down the whole test binary.
2026-08-05 17:28:56 +02:00
Stefan Haller 34da956f5d Don't let a broken fixture take down the whole test binary
A failing setup step called Shell.fail, which panicked. Tests run as
parallel subtests, so that panic aborted the entire test binary: one bad
fixture cost us the results of all ~500 tests, and the failure was
reported as a stack trace rather than against the test that caused it.

Keep panicking to skip the remaining setup steps -- they would only
produce follow-on failures -- but recover in createFixture and return the
message as that test's error. All three clients already propagate an
error from a test, so they report it the way they report any other
failure.
2026-08-05 17:02:06 +02:00
Stefan Haller 4ec91a0bf5 Don't let integration tests race a background git repack
Every `git commit` forks `git maintenance run --auto --quiet --detach`,
and git 2.54 changed what that runs from the `gc` task to the
"geometric" strategy. The geometric repack's auto condition passes its
threshold of 100 to too_many_loose_objects(), which estimates the loose
object count from the objects/17 fanout directory times 256, so the real
trigger is two objects in that one directory -- where the old gc task
needed 27. Fixture repos reach two easily: every CreateNCommits(n>=6)
repo already stores the blob for file06.txt there, so a single commit
object hashing into 17 (about 4% of fixtures with 10 commits, 15% with
40) tips it over, and from then on every commit in that repo forks a
detached `git repack -d`, which prunes loose objects while the next
fixture command -- or lazygit under test -- is still working in the same
repo.

That is where the CI panics during fixture setup come from:

    panic: error running command: [git commit -m commit-10]
        error: invalid object 100644 50d5612... for 'file09.txt'
        error: Error building trees

The reported hashes are exactly the fixture blobs, so `git add` staged
them correctly; they were unlinked underneath the commit. Only the
"git latest" jobs saw this, since the pinned 2.32/2.38/2.44 jobs predate
the strategy change.

git's own test suite guards against the same thing by exporting
GIT_TEST_MAINT_AUTO_DETACH=false ("Ensure that tests cannot race with
background maintenance by default"). Turning maintenance off outright is
stronger: no test repo needs it, and it also spares us a forked git
process per commit. maintenance.auto has been honored since git 2.29, so
it covers every version in the CI matrix.

Measured on a 40-commit fixture: a background repack fired in 4 of 25
runs before, 0 of 25 after.
2026-08-05 16:58:37 +02:00
77 changed files with 1922 additions and 546 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
check-required-label:
runs-on: ubuntu-latest
steps:
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
- uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5
with:
mode: exactly
count: 1
+6 -6
View File
@@ -30,7 +30,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
- name: Test code
@@ -93,7 +93,7 @@ jobs:
path: ~/git-${{matrix.git-version}}
key: ${{runner.os}}-git-${{matrix.git-version}}
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
- name: Print git version
@@ -130,7 +130,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
- name: Build linux binary
@@ -157,7 +157,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
- name: Check Vendor Directory
@@ -183,7 +183,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
- name: Check formatting
@@ -206,7 +206,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
+1 -1
View File
@@ -160,7 +160,7 @@ jobs:
git push origin "refs/tags/$NEW_TAG"
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.25.x
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: "README.md"
+20
View File
@@ -390,12 +390,32 @@ Avoid phrasings like:
- "cleaner than the previous approach"
- "we used to ... but ..."
- "after trying X, we found Y"
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The
code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists.
The tell is subtler than an explicit "we used to". A comment that justifies the
code against an alternative — "run it on a worker rather than blocking the UI",
"switch panels in `Then` rather than a moment earlier" — is history in disguise
whenever that alternative is what the code did before the change. It reads as
ordinary rationale, but the reader has no way to know the contrast is with a
version that no longer exists.
So the check to apply is: would you have written this comment if you were
writing the file from scratch, with no diff in mind? If not, the sentence
belongs in the commit message.
## Don't justify routine call sites
If the codebase calls a helper in twenty places without explanation, your
twenty-first call site doesn't need one either. A comment there says "something
here is unusual"; when nothing is, it's noise — and it invites exactly the kind
of before/after justification the section above warns about. Look at the
neighboring call sites before writing one: if they're bare, match them.
## Don't present "live with the bug" as an option
When you're investigating a defect and laying out fix options for the user,
Generated
+16 -16
View File
@@ -7,7 +7,7 @@
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69"
},
"original": {
"type": "tarball",
@@ -19,11 +19,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1759362264,
"narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=",
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "758cf7296bee11f1706a574c77d072b8a7baa881",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github"
},
"original": {
@@ -34,11 +34,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1759831965,
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
"lastModified": 1785828668,
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
"type": "github"
},
"original": {
@@ -50,11 +50,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1754788789,
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github"
},
"original": {
@@ -65,11 +65,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1754340878,
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4",
"type": "github"
},
"original": {
@@ -108,11 +108,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1758728421,
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"lastModified": 1785360170,
"narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0",
"type": "github"
},
"original": {
+2 -2
View File
@@ -109,8 +109,8 @@
};
treefmt = {
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler;
programs.nixfmt.package = pkgs.nixfmt;
programs.gofmt.enable = true;
};
+2 -2
View File
@@ -21,8 +21,8 @@ require (
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
github.com/kyokomi/emoji/v2 v2.2.13
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/kyokomi/emoji/v2 v2.2.14
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mgutz/str v1.2.0
github.com/mitchellh/go-ps v1.0.0
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
+4 -4
View File
@@ -73,10 +73,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+1 -1
View File
@@ -16,7 +16,7 @@ type errorMapping struct {
func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
errorMessage := err.Error()
knownErrorMessages := []string{minGitVersionErrorMessage(tr)}
knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported}
if lo.Contains(knownErrorMessages, errorMessage) {
return errorMessage, true
+15 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -67,11 +68,24 @@ func NewGitCommand(
return nil, errors.Errorf("Error getting repo paths: %v", err)
}
// A bare repo has no worktree for us to work in. Callers that can offer the
// user something better (app.setupRepo) check for this first; getting here
// means nobody could, e.g. because --git-dir was pointed at a bare repo.
if repoPaths.IsBareRepo() {
return nil, errors.New(cmn.Tr.BareRepoNotSupported)
}
err = os.Chdir(repoPaths.WorktreePath())
if err != nil {
return nil, utils.WrapError(err)
}
// Everything we run through the command builder gets told where the repo is
// by the builder itself, but subprocesses don't go through it: user-defined
// custom commands, an editor, and the lazygit we re-enter as git's sequence
// editor during a rebase. Put it in the process env for those.
env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars())
// Pin the config reads to the repo directory like all other git commands
// (see NewGitCmdObjBuilder); the config commands run outside that builder.
gitConfig.SetDir(repoPaths.WorktreePath())
@@ -94,7 +108,7 @@ func NewGitCommandAux(
repoPaths *git_commands.RepoPaths,
diffRendererConfigManager *config.DiffRendererConfigManager,
) *GitCommand {
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath())
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars())
// here we're doing a bunch of dependency injection for each of our commands structs.
// This is admittedly messy, but allows us to test each command struct in isolation,
+11 -3
View File
@@ -20,6 +20,13 @@ type gitCmdObjBuilder struct {
// the old builder) must keep running its commands against the repo it
// started in, not whichever one the process has since moved to.
repoDir string
// The env vars every command we produce gets: the optional-locks one below,
// plus the repo's git location if it has one (see
// RepoPaths.GitLocationEnvVars). Those are in the process env too, but for
// the same reason as repoDir we don't rely on that: the process env belongs
// to whichever repo lazygit has since switched to.
envVars []string
}
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
@@ -30,7 +37,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar.
var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0"
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder {
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []string) *gitCmdObjBuilder {
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
@@ -43,15 +50,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
return &gitCmdObjBuilder{
innerBuilder: updatedBuilder,
repoDir: repoDir,
envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...),
}
}
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir)
}
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir)
}
func (self *gitCmdObjBuilder) Quote(str string) string {
+20
View File
@@ -18,6 +18,7 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo",
nil,
)
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
@@ -34,8 +35,27 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo",
nil,
)
assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir)
assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir)
}
// A repo whose git dir isn't in its worktree can't be found by running a
// command there, so the builder has to tell every command where it is; see
// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only
// for the repo lazygit is in right now, which isn't necessarily this one.
func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) {
builder := NewGitCmdObjBuilder(
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/worktree",
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
)
assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
}
+60
View File
@@ -8,6 +8,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
type FileLoaderConfig interface {
@@ -88,6 +89,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
files = append(files, file)
}
self.setConflictMarkerSizes(files)
// Go through the files to see if any of these files are actually worktrees
// so that we can render them correctly
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
@@ -111,6 +114,63 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
return files
}
// Looks up how long the conflict markers in the conflicted files are. We ask
// git for all of them at once, because spawning a process per file would be
// painfully slow when hundreds of files are conflicted (especially on Windows).
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) {
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool {
return file.HasInlineMergeConflicts
})
if len(conflictedFiles) == 0 {
return
}
paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string {
return file.Path
})
markerSizes, err := self.getConflictMarkerSizes(paths)
if err != nil {
self.Log.Error(err)
return
}
for _, file := range conflictedFiles {
file.ConflictMarkerSize = markerSizes[file.Path]
}
}
func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) {
cmdArgs := NewGitCmd("check-attr").
Arg("-z").
Arg("--stdin").
Arg("conflict-marker-size").
ToArgv()
// -z makes git both read the paths and write its output NUL-separated, so
// that paths containing newlines don't throw us off.
output, _, err := self.cmd.New(cmdArgs).
SetStdin(strings.Join(paths, "\x00")).
DontLog().
RunWithOutputs()
if err != nil {
return nil, err
}
markerSizes := map[string]int{}
fields := strings.Split(output, "\x00")
// Each path yields a path/attribute/value triple; the value is either a
// number or something like "unspecified", in which case we leave the marker
// size at 0 to say that git's default applies.
for i := 0; i+2 < len(fields); i += 3 {
if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 {
markerSizes[fields[i]] = markerSize
}
}
return markerSizes, nil
}
type FileDiff struct {
LinesAdded int
LinesDeleted int
@@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) {
ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"},
"4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt",
nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file5.txt\x00conflict-marker-size\x00unspecified\x00",
nil,
),
showNumstatInFilesView: true,
expectedFiles: []*models.File{
@@ -112,6 +116,58 @@ func TestFileGetStatusFiles(t *testing.T) {
},
},
},
{
testName: "Conflicted files with a conflict-marker-size attribute",
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
"UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt",
nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file1.txt\x00conflict-marker-size\x0032\x00"+
"file2.txt\x00conflict-marker-size\x00unspecified\x00"+
"file3.txt\x00conflict-marker-size\x00nonsense\x00",
nil,
),
expectedFiles: []*models.File{
{
Path: "file1.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
ConflictMarkerSize: 32,
DisplayString: "UU file1.txt",
ShortStatus: "UU",
},
{
Path: "file2.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file2.txt",
ShortStatus: "UU",
},
{
Path: "file3.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file3.txt",
ShortStatus: "UU",
},
{
Path: "file4.txt",
HasUnstagedChanges: true,
Tracked: true,
DisplayString: " M file4.txt",
ShortStatus: " M",
},
},
},
{
testName: "File with new line char",
similarityThreshold: 50,
@@ -6,6 +6,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
)
// OptionalLocksEnvVar is the name of the environment variable that tells git
@@ -18,6 +19,15 @@ 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
// 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 {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}
// convenience struct for building git commands. Especially useful when
// including conditional args
type GitCommandBuilder struct {
+46 -2
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
@@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
return queryString, variables
}
// GetAuthToken returns the token to authenticate against the given host with,
// or an empty string if there is none.
//
// The token has to come from gh itself rather than from an in-process lookup
// with go-gh: that reads gh's config file once per process and answers from
// that snapshot ever after, whereas gh rewrites the file whenever the active
// account changes, and keeps the active account's token either there or in the
// system keyring. Under a long-running lazygit the snapshot therefore drifts
// out of date, leaving us with a token for an account that is no longer active,
// or with no token at all.
func (self *GitHubCommands) GetAuthToken(host string) string {
token, _ := auth.TokenForHost(host)
return token
ghExe := ghExecutable()
if ghExe == "" {
// Without gh installed, the environment variables and config file that
// gh would have consulted are still worth a look.
token, _ := auth.TokenFromEnvOrConfig(host)
return token
}
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
// Not being logged in to this host is a normal state rather than
// something to report; the runner logs gh's stderr for the rest.
return ""
}
return strings.TrimSpace(output)
}
// ghExecutable returns the path of the gh binary, or an empty string if it
// isn't installed.
func ghExecutable() string {
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
return ghExe
}
// A gh found in the current directory rather than on PATH comes back as
// exec.ErrDot, which we treat as not having found one at all.
ghExe, err := exec.LookPath("gh")
if err != nil {
return ""
}
return ghExe
}
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
+178 -13
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
@@ -19,10 +20,12 @@ type RepoPaths struct {
repoGitDirPath string
repoName string
isBareRepo bool
gitLocationEnvVars []string
}
// Path to the current worktree. If we're in the main worktree, this will
// be the same as RepoPath()
// be the same as RepoPath(). It is empty for a bare repo, which has no
// worktree at all.
func (self *RepoPaths) WorktreePath() string {
return self.worktreePath
}
@@ -53,10 +56,33 @@ func (self *RepoPaths) RepoName() string {
return self.repoName
}
// Whether we found no worktree, so that there is nothing for lazygit to show.
// Note that this isn't quite git's core.bare: a repo that calls itself non-bare
// but whose worktree we couldn't find counts as bare for us too. Concretely,
// this is true when we're in
//
// - a genuinely bare repo;
// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is
// recorded but not somewhere we look;
// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such
// as a vcsh-style dotfiles repo that hasn't been given core.worktree.
//
// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir
// notices the worktree holding it and hands back that repo instead.
func (self *RepoPaths) IsBareRepo() bool {
return self.isBareRepo
}
// The environment that tells git where this repo is, as "NAME=value" entries.
// It is empty for the vast majority of repos, which git finds for itself by
// looking for a .git in the directory a command runs in. It is only non-empty
// when that doesn't work — when the git dir lives somewhere else entirely,
// because of core.worktree or --work-tree — and then every command addressing
// the repo has to carry it.
func (self *RepoPaths) GitLocationEnvVars() []string {
return self.gitLocationEnvVars
}
// Returns the repo paths for a typical repo
func MockRepoPaths(currentPath string) *RepoPaths {
return &RepoPaths{
@@ -84,26 +110,76 @@ func GetRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree")
repoPaths, err := repoPathsForDir(dir, cmd)
if err != nil || !repoPaths.IsBareRepo() {
return repoPaths, err
}
// We're in a git dir rather than in a working tree, which usually just means
// somebody ran lazygit in the .git of an ordinary repo. git's convention is
// that a git dir called .git belongs to the directory holding it, so look
// there: if that is a working tree, it is the repo we were asked about, and
// there's no reason to make the user go up a directory and try again.
//
// The git dirs that aren't called .git keep the paths we have. A linked
// worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a
// working tree, but only the directory holding a .git tells us where, so we
// would be guessing. A bare repo's has none to find.
if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" {
return repoPaths, nil
}
pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd)
if err != nil || pathsFromWorkTree.IsBareRepo() {
return repoPaths, nil
}
return pathsFromWorkTree, nil
}
// repoPathsForDir asks git about the repo at dir, and reports a bare repo when
// there is no working tree there. Unlike GetRepoPathsForDir it never looks
// anywhere but dir, which is what keeps that one from going round in circles.
func repoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree")
if err != nil {
return nil, err
// --show-toplevel is the only one of these that needs a work tree, and
// git makes it fatal when there isn't one. So this may just mean we're in
// a repo that has no work tree.
return getBareRepoPathsForDir(dir, cmd, err)
}
gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n")
worktreePath := gitDirResults[0]
worktreeGitDirPath := gitDirResults[1]
repoGitDirPath := gitDirResults[2]
isBareRepo := gitDirResults[3] == "true"
// If we're in a submodule, --show-superproject-working-tree will return
// a value, meaning gitDirResults will be length 5. In that case
// return the worktree path as the repoPath. Otherwise we're in a
// normal repo or a worktree so return the parent of the git common
// dir (repoGitDirPath)
isSubmodule := len(gitDirResults) == 5
// A worktree that has the repo's common git dir to itself is the repo's main
// worktree, so it is the repoPath. That holds for a submodule as well: its
// git dir lives under the superproject's .git/modules, but it is still the
// submodule's own common dir.
isMainWorktree := worktreeGitDirPath == repoGitDirPath
// If we're in a submodule, --show-superproject-working-tree will return a
// value, meaning gitDirResults will be length 4. That only tells us anything
// new for a linked worktree of a submodule, which isMainWorktree misses.
isSubmodule := len(gitDirResults) == 4
// Otherwise we're in a linked worktree, and the repoPath is the repo's main
// worktree. git won't tell us where that is: `git worktree list` reports it
// as the common git dir with a trailing "/.git" removed, which is this same
// derivation. So take the directory holding the common git dir. That is the
// main worktree of an ordinary repo, and of a bare one it is the directory
// its worktrees live in. It is not the main worktree of a repo that moved
// that elsewhere with core.worktree; there we end up naming the git dir's
// directory, which means that the repo name we display in the status panel
// isn't correct, and we start looking for .lazygit.yml in the wrong place.
// Both of those are not severe enough to justify the extra git call to get
// the real main worktree, so we accept this for this rather niche use case.
var repoPath string
if isSubmodule {
if isMainWorktree || isSubmodule {
repoPath = worktreePath
} else {
repoPath = filepath.Dir(repoGitDirPath)
@@ -116,21 +192,110 @@ func GetRepoPathsForDir(
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: repoName,
isBareRepo: isBareRepo,
isBareRepo: false,
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
}, nil
}
// gitLocationEnvVars works out whether git can find the repo by itself when a
// command runs in its worktree, and if it can't, returns the environment that
// tells git where it is. See RepoPaths.GitLocationEnvVars.
func gitLocationEnvVars(
cmd oscommands.ICmdObjBuilder,
worktreePath string,
worktreeGitDirPath string,
) []string {
// The ordinary repo, where the git dir sits in the worktree. Both paths are
// git's own answers from the same invocation, so they are spelled alike and
// comparing them is safe.
if worktreeGitDirPath == filepath.Join(worktreePath, ".git") {
return nil
}
// A linked worktree or a submodule instead has a .git file naming its git
// dir, and git follows that just as happily. We could read the file, but the
// path in it may well name the same directory differently than git did
// above, so ask git to resolve it — from the worktree and nothing else.
discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir")
if err == nil && discoveredGitDirPath == worktreeGitDirPath {
return nil
}
return []string{
env.GitDirEnvVar + "=" + worktreeGitDirPath,
env.GitWorkTreeEnvVar + "=" + worktreePath,
}
}
// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the
// work tree. Everything but --show-toplevel works fine without one, so if the
// remaining queries succeed we are in a bare repo, and we return what we know
// about it with an empty worktreePath. If they fail too we simply aren't in a
// repo, and the caller's original error says so better than ours would.
func getBareRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
errWithWorktree error,
) (*RepoPaths, error) {
output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir")
if err != nil {
return nil, errWithWorktree
}
results := strings.Split(utils.NormalizeLinefeeds(output), "\n")
repoGitDirPath := results[1]
// A bare repo has no worktree, and so no repo path in the sense the caller
// with a worktree means. It doesn't matter much what we say here, because
// nobody reads it: whoever is handed a bare repo either offers to open a
// recent one instead (app.setupRepo) or is turned away by NewGitCommand. The
// directory holding the git dir is the nearest thing there is to a repo
// path.
repoPath := filepath.Dir(repoGitDirPath)
return &RepoPaths{
worktreePath: "",
worktreeGitDirPath: results[0],
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: filepath.Base(repoPath),
isBareRepo: true,
}, nil
}
// Asks git about the repo at dir. This is how we find our own repo, so it has
// to be answered the way git itself would answer it there, GIT_DIR and
// GIT_WORK_TREE included.
func callGitRevParseWithDir(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// 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...)))
}
func newGitRevParseCmd(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) *oscommands.CmdObj {
gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...)
if dir != "" {
gitRevParse.Dir(dir)
}
gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog()
return cmd.New(gitRevParse.ToArgv()).DontLog()
}
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
res, err := gitCmd.RunWithOutput()
if err != nil {
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
+139 -39
View File
@@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git`,
// --git-common-dir
`C:\path\to\repo\.git`,
// --is-bare-repository
"false",
// --show-superproject-working-tree
}, []string{
// --show-toplevel
@@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git",
// --git-common-dir
"/path/to/repo/.git",
// --is-bare-repository
"false",
// --show-superproject-working-tree
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
},
@@ -76,53 +72,147 @@ func TestGetRepoPaths(t *testing.T) {
Err: nil,
},
{
// git refuses to answer --show-toplevel when there's no work tree, so
// we have to ask a second time without it.
Name: "bare repo",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
// setup for main worktree
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --show-toplevel
`C:\path\to\repo`,
// --git-dir
`C:\path\to\bare_repo\bare.git`,
`C:\path\to\project\bare.git`,
// --git-common-dir
`C:\path\to\bare_repo\bare.git`,
// --is-bare-repository
`true`,
// --show-superproject-working-tree
`C:\path\to\project\bare.git`,
}, []string{
// --show-toplevel
"/path/to/repo",
// --git-dir
"/path/to/bare_repo/bare.git",
"/path/to/project/bare.git",
// --git-common-dir
"/path/to/bare_repo/bare.git",
// --is-bare-repository
"true",
// --show-superproject-working-tree
"/path/to/project/bare.git",
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join(mockOutput, "\n"),
nil)
},
Path: "/path/to/repo",
Path: "/path/to/project",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoPath: `C:\path\to\bare_repo`,
repoGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoName: `bare_repo`,
worktreePath: "",
worktreeGitDirPath: `C:\path\to\project\bare.git`,
repoPath: `C:\path\to\project`,
repoGitDirPath: `C:\path\to\project\bare.git`,
repoName: `project`,
isBareRepo: true,
}, &RepoPaths{
worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/bare_repo/bare.git",
repoPath: "/path/to/bare_repo",
repoGitDirPath: "/path/to/bare_repo/bare.git",
repoName: "bare_repo",
worktreePath: "",
worktreeGitDirPath: "/path/to/project/bare.git",
repoPath: "/path/to/project",
repoGitDirPath: "/path/to/project/bare.git",
repoName: "project",
isBareRepo: true,
}),
Err: nil,
},
{
// Standing in the .git dir of an ordinary repo: git refuses to name a
// work tree, but the directory holding the .git is one, so we open the
// repo from there.
Name: "in a repo's .git dir",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git")
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo")
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join([]string{gitDir, gitDir}, "\n"),
nil)
// asking again from the directory holding the .git
runner.ExpectGitArgs(
append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join([]string{worktree, gitDir, gitDir}, "\n"),
nil)
},
Path: "/path/to/repo/.git",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\repo\.git`,
repoPath: `C:\path\to\repo`,
repoGitDirPath: `C:\path\to\repo\.git`,
repoName: `repo`,
isBareRepo: false,
}, &RepoPaths{
worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/repo/.git",
repoPath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
repoName: "repo",
isBareRepo: false,
}),
Err: nil,
},
{
// A repo whose work tree lives somewhere else entirely, as set up by
// core.worktree or by --work-tree. We're in the main worktree, but the
// git dir is not inside it.
Name: "repo with a separate work tree",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --show-toplevel
`C:\path\to\worktree`,
// --git-dir
`C:\path\to\repo\.git`,
// --git-common-dir
`C:\path\to\repo\.git`,
// --show-superproject-working-tree
}, []string{
// --show-toplevel
"/path/to/worktree",
// --git-dir
"/path/to/repo/.git",
// --git-common-dir
"/path/to/repo/.git",
// --show-superproject-working-tree
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
// asking git to find the repo from the work tree gets us nowhere,
// because there is no .git there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
"",
errors.New("fatal: not a git repository (or any of the parent directories): .git"))
},
Path: "/path/to/repo",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\worktree`,
worktreeGitDirPath: `C:\path\to\repo\.git`,
repoPath: `C:\path\to\worktree`,
repoGitDirPath: `C:\path\to\repo\.git`,
repoName: `worktree`,
isBareRepo: false,
gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`},
}, &RepoPaths{
worktreePath: "/path/to/worktree",
worktreeGitDirPath: "/path/to/repo/.git",
repoPath: "/path/to/worktree",
repoGitDirPath: "/path/to/repo/.git",
repoName: "worktree",
isBareRepo: false,
gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
}),
Err: nil,
},
{
Name: "submodule",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
@@ -133,8 +223,6 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git\modules\submodule1`,
// --git-common-dir
`C:\path\to\repo\.git\modules\submodule1`,
// --is-bare-repository
`false`,
// --show-superproject-working-tree
`C:\path\to\repo`,
}, []string{
@@ -144,15 +232,22 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git/modules/submodule1",
// --git-common-dir
"/path/to/repo/.git/modules/submodule1",
// --is-bare-repository
"false",
// --show-superproject-working-tree
"/path/to/repo",
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
// git finds the submodule's git dir from its work tree, via the
// .git file there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1")
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
gitDir,
nil)
},
Path: "/path/to/repo/submodule1",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
@@ -176,7 +271,12 @@ func TestGetRepoPaths(t *testing.T) {
Name: "git rev-parse returns an error",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
// we're not in a repo at all, so asking about a bare one fails too
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
"",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
},
@@ -184,7 +284,7 @@ func TestGetRepoPaths(t *testing.T) {
Expected: nil,
Err: func(getRevParseArgs argFn) error {
args := strings.Join(getRevParseArgs(), " ")
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
},
},
}
+13 -11
View File
@@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false").
ToArgv()
summary, err := 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 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 self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
@@ -195,20 +195,15 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked").
ToArgv()
return self.cmd.New(cmdArgs).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
parentDir := ""
if submodule.ParentModule != nil {
parentDir = submodule.ParentModule.FullPath()
}
cmdArgs := NewGitCmd("submodule").
Arg("update", "--init", "--force", "--", submodule.Path).
DirIf(parentDir != "", parentDir).
ToArgv()
return self.cmd.New(cmdArgs).Run()
return self.runInParentModule(submodule, self.cmd.New(cmdArgs))
}
func (self *SubmoduleCommands) UpdateAll() error {
@@ -225,9 +220,16 @@ func (self *SubmoduleCommands) UpdateAll() error {
// temporarily chdir-ing the process there, which would leak the parent
// module's directory into whatever other commands run concurrently (e.g. a
// background refresh's).
//
// That directory is relative, so it resolves against the process working
// directory rather than against the repo directory the command builder
// otherwise pins commands to. Only foreground commands the user issued end up
// here, and lazygit won't switch repos while one of those is in flight, so the
// two are the same directory; don't call this from background work, where they
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil {
cmdObj.SetWd(submodule.ParentModule.FullPath())
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
}
return cmdObj.Run()
}
@@ -1,10 +1,13 @@
package git_commands
import (
"strings"
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
runner.CheckForMissingCalls()
}
// A command that runs inside a submodule mustn't inherit the GIT_DIR and
// GIT_WORK_TREE that say where the superproject is; git would answer it from
// there instead, and the answer would look perfectly plausible.
func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) {
t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git")
t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo")
runner := oscommands.NewFakeRunner(t).
ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool {
return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool {
return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") ||
strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=")
})
}, "bbbbbbb the subject\n", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
_, err := instance.GetCommitSummary("mysub", "bbbbbbb")
assert.NoError(t, err)
runner.CheckForMissingCalls()
}
func TestSubmoduleConflictSideLog(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
+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 self.cmd.New(cmdArgs).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
+21 -11
View File
@@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader {
}
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
currentRepoPath := self.repoPaths.RepoPath()
worktreePath := self.repoPaths.WorktreePath()
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv()
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
@@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if strings.HasPrefix(splitLine, "worktree ") {
path := strings.SplitN(splitLine, " ", 2)[1]
isMain := path == currentRepoPath
isCurrent := path == worktreePath
isPathMissing := self.pathExists(path)
current = &models.Worktree{
IsMain: isMain,
IsCurrent: isCurrent,
IsPathMissing: isPathMissing,
IsPathMissing: self.pathExists(path),
Path: path,
// we defer populating GitDir until a loop below so that
// we can parallelize the calls to git rev-parse
// we can parallelize the calls to git rev-parse, and
// IsMain/IsCurrent because they are derived from GitDir
GitDir: "",
}
} else if strings.HasPrefix(splitLine, "HEAD ") {
@@ -84,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if worktree.IsPathMissing {
return
}
gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir")
gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir")
if err != nil {
self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err)
return
@@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
}
wg.Wait()
// Identify the current and the main worktree by their git dir rather than by
// their path: `git worktree list` reports the main worktree as the common
// git dir with a trailing "/.git" removed, which is the working tree only
// when the git dir sits inside it. In a submodule, a bare repo or a repo
// using core.worktree it doesn't, and comparing paths then matches nothing.
// A worktree whose directory is gone has no git dir to compare, so there we
// have nothing better than its path.
for _, worktree := range worktrees {
if worktree.GitDir != "" {
worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath()
worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath()
} else {
worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath()
worktree.IsMain = worktree.Path == self.repoPaths.RepoPath()
}
}
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
return worktree.Path
}))
@@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) {
{
testName: "Single worktree (main)",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -55,8 +57,10 @@ branch refs/heads/mybranch
{
testName: "Multiple worktrees (main + linked)",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree
{
testName: "Worktree missing path",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -136,8 +142,10 @@ branch refs/heads/missingbranch
{
testName: "In linked worktree",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo-worktree",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo-worktree",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -184,11 +192,51 @@ branch refs/heads/mybranch-worktree
},
expectedErr: "",
},
{
testName: "In a submodule",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo/mysubmodule",
worktreePath: "/path/to/repo/mysubmodule",
repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
// A submodule's git dir doesn't live inside its working tree, and
// `git worktree list` reports the git dir rather than the working
// tree it belongs to.
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
`worktree /path/to/repo/.git/modules/mysubmodule
HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d
branch refs/heads/mybranch
`,
nil)
gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir")
runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil)
_ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755)
},
expectedWorktrees: []*models.Worktree{
{
IsMain: true,
IsCurrent: true,
Path: "/path/to/repo/.git/modules/mysubmodule",
IsPathMissing: false,
GitDir: "/path/to/repo/.git/modules/mysubmodule",
Branch: "mybranch",
Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d",
Name: "mysubmodule",
},
},
expectedErr: "",
},
{
testName: "Detached HEAD worktree",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
+8 -4
View File
@@ -18,10 +18,14 @@ type File struct {
Deleted bool
HasMergeConflicts bool
HasInlineMergeConflicts bool
DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// How long the conflict markers in this file are, taken from its
// conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We
// only look this up for files that have inline merge conflicts.
ConflictMarkerSize int
DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// If true, this must be a worktree folder
IsWorktree bool
+40 -30
View File
@@ -1,12 +1,12 @@
package oscommands
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/go-errors/errors"
"github.com/samber/lo"
@@ -228,37 +228,47 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
// keeping this here in case I adapt this code for some other purpose in the future
// cmds[len(cmds)-1].Stdout = os.Stdout
finalErrors := []string{}
wg := sync.WaitGroup{}
wg.Add(len(cmds))
for _, cmd := range cmds {
go utils.Safe(func() {
stderr, err := cmd.StderrPipe()
if err != nil {
c.Log.Error(err)
}
if err := cmd.Start(); err != nil {
c.Log.Error(err)
}
if b, err := io.ReadAll(stderr); err == nil {
if len(b) > 0 {
finalErrors = append(finalErrors, string(b))
}
}
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
wg.Done()
})
stderrs := make([]bytes.Buffer, len(cmds))
for i := range cmds {
cmds[i].Stderr = &stderrs[i]
}
wg.Wait()
// 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++
}
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] {
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
if stderrs[i].Len() > 0 {
finalErrors = append(finalErrors, stderrs[i].String())
}
}
if len(finalErrors) > 0 {
return errors.New(strings.Join(finalErrors, "\n"))
+38 -6
View File
@@ -2,27 +2,59 @@ package env
import (
"os"
"strings"
)
// This package encapsulates accessing/mutating the ENV of the program.
// The variables with which git can be told where a repo is, rather than having
// it find out from the working directory.
const (
GitDirEnvVar = "GIT_DIR"
GitWorkTreeEnvVar = "GIT_WORK_TREE"
)
func GetGitDirEnv() string {
return os.Getenv("GIT_DIR")
return os.Getenv(GitDirEnvVar)
}
func SetGitDirEnv(value string) {
os.Setenv("GIT_DIR", value)
os.Setenv(GitDirEnvVar, value)
}
func GetWorkTreeEnv() string {
return os.Getenv("GIT_WORK_TREE")
return os.Getenv(GitWorkTreeEnvVar)
}
func SetWorkTreeEnv(value string) {
os.Setenv("GIT_WORK_TREE", value)
os.Setenv(GitWorkTreeEnvVar, value)
}
func UnsetGitLocationEnvVars() {
_ = os.Unsetenv("GIT_DIR")
_ = os.Unsetenv("GIT_WORK_TREE")
_ = os.Unsetenv(GitDirEnvVar)
_ = os.Unsetenv(GitWorkTreeEnvVar)
}
// GetGitLocationEnvVars returns the location variables that are set, as
// "NAME=value" entries.
func GetGitLocationEnvVars() []string {
envVars := []string{}
for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} {
if value := os.Getenv(name); value != "" {
envVars = append(envVars, name+"="+value)
}
}
return envVars
}
// SetGitLocationEnvVars sets the location variables from "NAME=value" entries,
// clearing both first so that only what is given remains. Passing nothing is
// how you say the repo is to be found from the working directory.
func SetGitLocationEnvVars(envVars []string) {
UnsetGitLocationEnvVars()
for _, envVar := range envVars {
if name, value, ok := strings.Cut(envVar, "="); ok {
os.Setenv(name, value)
}
}
}
+27 -8
View File
@@ -38,6 +38,11 @@ var (
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
// returned. Nothing dequeues user events after that, so the callback it was
// asked to run on the main goroutine never will be.
ErrLoopExited = standardErrors.New("main loop exited")
)
const (
@@ -893,36 +898,50 @@ func (g *Gui) EndBlockingEvents() error {
}
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
// caller until f has run, returning f's error. Use it to read UI-thread-owned
// state (the model, contexts) from a worker without racing the UI thread.
// caller until f has run. Use it to read UI-thread-owned state (the model,
// contexts) from a worker without racing the UI thread.
//
// The error it returns is the wait's own, never f's: it reports that f was not
// run at all, which happens when the main loop has exited (ErrLoopExited). f
// doesn't report an error because what callers want on the UI thread — reading
// and mutating state — doesn't fail.
//
// It must be called from a worker goroutine, never from the UI thread itself:
// the UI thread would block waiting for a callback only it can run, which
// deadlocks. Callers arrange this by construction (see the refresh helper's
// RefreshFromWorker); a debug-only assertion there guards against getting it
// wrong.
func (g *Gui) OnUIThreadAndWait(f func() error) error {
func (g *Gui) OnUIThreadAndWait(f func()) error {
return g.onUIThreadAndWait(f, false)
}
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
// so it doesn't count towards the program being busy (see UpdateBackground).
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
return g.onUIThreadAndWait(f, true)
}
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
enqueue := g.Update
if background {
enqueue = g.UpdateBackground
}
result := make(chan error, 1)
ran := make(chan struct{})
enqueue(func(*Gui) error {
result <- f()
f()
close(ran)
return nil
})
return <-result
select {
case <-ran:
return nil
case <-g.loopExited:
// The queue we just enqueued onto is no longer being served, so waiting
// on `ran` here would mean waiting for the rest of the process's life.
return ErrLoopExited
}
}
// Calls a function in a goroutine. Handles panics gracefully and tracks
+43
View File
@@ -0,0 +1,43 @@
package gocui
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// errStillWaiting stands in for the result of a wait that hasn't produced one.
var errStillWaiting = errors.New("still waiting")
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
// returned by the time we give up on it.
func resultOrTimeout(result chan error) error {
select {
case err := <-result:
return err
case <-time.After(time.Second):
return errStillWaiting
}
}
// A worker waiting for the UI thread must not be left parked there once the
// main loop has stopped: nothing will ever run its callback, and the shutdown
// that follows blocks until such workers have finished (see
// tasks.ViewBufferManager.Close).
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
g := newTestGui(t)
// Closing this is what MainLoop returning does. From here on nothing
// dequeues user events, so the callback below is never going to run.
close(g.loopExited)
result := make(chan error, 1)
go func() {
result <- g.OnUIThreadAndWait(func() {})
}()
err := resultOrTimeout(result)
assert.ErrorIs(t, err, ErrLoopExited)
}
+2 -4
View File
@@ -119,13 +119,12 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
var appStatusHelper *helpers.AppStatusHelper
var branchesHelper *helpers.BranchesHelper
var fetchGeneration int
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
git = self.gui.git
appStatusHelper = self.gui.helpers.AppStatus
branchesHelper = self.gui.helpers.BranchesHelper
fetchGeneration = self.gui.c.State().GetRepoGeneration()
self.gui.State.LastBackgroundFetchTime = time.Now()
return nil
}); err != nil {
return err
}
@@ -184,10 +183,9 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
// reading them from this background goroutine would race the reassignment.
var git *commands.GitCommand
var refreshHelper *helpers.RefreshHelper
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
git = self.gui.git
refreshHelper = self.gui.helpers.Refresh
return nil
}); err != nil {
return
}
+7 -1
View File
@@ -324,7 +324,13 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit {
}
func shouldShowGraph(c *ContextCommon) bool {
if c.Modes().Filtering.Active() {
// Whether we can draw a graph is a property of the commit list we have
// loaded, not of the filtering mode: turning filtering on or off only
// reaches the screen when the reloaded list does, and until then the graph
// has to keep matching the list that is still on display. Drawing one for a
// filtered list is also ruinously slow, because none of the commits in it
// are connected to each other, so no pipe ever terminates.
if c.Model().CommitsWereFilteredAtLastRefresh {
return false
}
@@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
HandleConfirm: func() error {
commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
+15 -8
View File
@@ -328,7 +328,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
// (it was resolved in an editor), in which case the caller should fall back to
// showing the file's diff.
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File)
if err != nil {
return true
}
@@ -1264,7 +1264,7 @@ func (self *FilesController) switchToMerge() error {
return nil
}
return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path)
return self.c.Helpers().MergeConflicts.SwitchToMerge(file)
}
func (self *FilesController) createStashMenu() error {
@@ -1508,13 +1508,20 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.StashChanges,
HandleConfirm: func(stashComment string) error {
self.c.LogAction(action)
return self.c.WithWaitingStatusBlockingInput(
types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus},
func(gocui.Task) error {
self.c.LogAction(action)
if err := stashFunc(stashComment); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
return nil
if err := stashFunc(stashComment); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
return nil
})
},
AllowEmptyInput: true,
})
+4 -36
View File
@@ -3,7 +3,6 @@ package controllers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -42,7 +41,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName),
OnPress: func() error {
return self.setFilteringPath(fileName)
return self.c.Helpers().Mode.SetFilteringPath(fileName)
},
Tooltip: tooltip,
})
@@ -52,7 +51,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{
Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author),
OnPress: func() error {
return self.setFilteringAuthor(author)
return self.c.Helpers().Mode.SetFilteringAuthor(author)
},
Tooltip: tooltip,
})
@@ -65,7 +64,7 @@ func (self *FilteringMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(),
Title: self.c.Tr.EnterFileName,
HandleConfirm: func(response string) error {
return self.setFilteringPath(response)
return self.c.Helpers().Mode.SetFilteringPath(response)
},
})
@@ -81,7 +80,7 @@ func (self *FilteringMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
Title: self.c.Tr.EnterAuthor,
HandleConfirm: func(response string) error {
return self.setFilteringAuthor(response)
return self.c.Helpers().Mode.SetFilteringAuthor(response)
},
})
@@ -99,34 +98,3 @@ func (self *FilteringMenuAction) Call() error {
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems})
}
func (self *FilteringMenuAction) setFilteringPath(path string) error {
self.c.Modes().Filtering.Reset()
self.c.Modes().Filtering.SetPath(path)
return self.setFiltering()
}
func (self *FilteringMenuAction) setFilteringAuthor(author string) error {
self.c.Modes().Filtering.Reset()
self.c.Modes().Filtering.SetAuthor(author)
return self.setFiltering()
}
func (self *FilteringMenuAction) setFiltering() error {
self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash())
repoState := self.c.State().GetRepoState()
if repoState.GetScreenMode() == types.SCREEN_NORMAL {
repoState.SetScreenMode(types.SCREEN_HALF)
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error {
self.c.Contexts().LocalCommits.SetSelection(0)
self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{})
return nil
}})
return nil
}
@@ -85,30 +85,32 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks
// keyboard input for the whole duration of the operation: keys the user presses
// while it runs are buffered and replayed against the post-operation state (see
// gocui.BeginBlockingEvents). Use it for operations that manipulate an
// in-progress rebase or otherwise rewrite commits, where a racing keypress
// would target the wrong commit or todo.
// gocui.BeginBlockingEvents). Use it for operations whose following keypress
// depends on the state they produce, e.g. ones that manipulate an in-progress
// rebase or otherwise rewrite commits, where a racing keypress would target the
// wrong commit or todo.
//
// Must be called on the UI thread: the block is begun synchronously here, before
// the operation is dispatched to a worker, so no keypress can slip through in
// between.
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) {
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) {
self.c.GocuiGui().BeginBlockingEvents()
// Hide the rebasing-mode indicator (and its reset button) while we drive the
// rebase ourselves; it reflects the transient on-disk state and would
// otherwise flash on for the duration of the operation.
self.modeHelper.SetSuppressRebasingMode(true)
if opts.HideWorkingTreeState {
self.modeHelper.SetSuppressWorkingTreeStateMode(true)
}
self.c.OnWorker(func(task gocui.Task) error {
// End the block and restore the mode indicator once the operation and its
// refresh have applied their UI updates: OnUIThread queues this after the
// refresh's model bounces and Then (which RefreshFromWorker has already
// enqueued by the time f returns), so the replayed keys act on the
// refreshed state and any resulting rebase state shows correctly.
// refreshed state and any resulting working tree state shows correctly.
defer self.c.OnUIThread(func() error {
self.modeHelper.SetSuppressRebasingMode(false)
if opts.HideWorkingTreeState {
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
}
return self.c.GocuiGui().EndBlockingEvents()
})
return self.WithWaitingStatusImpl(message, f, task)
return self.WithWaitingStatusImpl(opts.Message, f, task)
})
}
@@ -85,7 +85,10 @@ func (self *CherryPickHelper) Paste() error {
HandleConfirm: func() error {
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
cherryPickedCommits := self.getData().CherryPickedCommits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.CherryPickingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CherryPick)
if mustStash {
@@ -188,9 +188,8 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
}
result := false
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
result = check()
return nil
})
return result
}
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -17,14 +18,14 @@ func NewMergeConflictsHelper(
}
}
func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) {
func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
return self.setMergeStateWithoutLock(path)
return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize)
}
func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) {
func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) {
content, err := self.c.Git().File.Cat(path)
if err != nil {
return false, err
@@ -34,7 +35,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, e
self.context().SetUserScrolling(false)
}
self.context().GetState().SetContent(content, path)
self.context().GetState().SetContent(content, path, markerSize)
return !self.context().GetState().NoConflicts(), nil
}
@@ -72,7 +73,8 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath())
state := self.context().GetState()
hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize())
if err != nil {
return false, err
}
@@ -84,9 +86,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
return false, nil
}
func (self *MergeConflictsHelper) SwitchToMerge(path string) error {
if self.context().GetState().GetPath() != path {
hasConflicts, err := self.SetMergeState(path)
func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error {
if self.context().GetState().GetPath() != file.Path {
hasConflicts, err := self.SetMergeState(file)
if err != nil {
return err
}
+94 -20
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
@@ -12,12 +13,12 @@ import (
type ModeHelper struct {
c *HelperCommon
diffHelper *DiffHelper
patchBuildingHelper *PatchBuildingHelper
cherryPickHelper *CherryPickHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
bisectHelper *BisectHelper
suppressRebasingMode bool
diffHelper *DiffHelper
patchBuildingHelper *PatchBuildingHelper
cherryPickHelper *CherryPickHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
bisectHelper *BisectHelper
suppressWorkingTreeStateMode bool
}
func NewModeHelper(
@@ -130,7 +131,7 @@ func (self *ModeHelper) Statuses() []ModeStatus {
},
{
IsActive: func() bool {
return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any()
return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any()
},
InfoLabel: func() string {
workingTreeState := self.c.Git().Status.WorkingTreeState()
@@ -182,16 +183,39 @@ func (self *ModeHelper) ExitFilterMode() error {
return self.ClearFiltering()
}
func (self *ModeHelper) SetFilteringPath(path string) error {
return self.setFiltering(func() {
self.c.Modes().Filtering.SetPath(path)
})
}
func (self *ModeHelper) SetFilteringAuthor(author string) error {
return self.setFiltering(func() {
self.c.Modes().Filtering.SetAuthor(author)
})
}
func (self *ModeHelper) setFiltering(setFilter func()) error {
return self.changeFiltering(
func() {
// Whatever we were filtering by before is replaced, not added to
self.c.Modes().Filtering.Reset()
setFilter()
self.c.Modes().Filtering.SetSelectedCommitHash(
self.c.Contexts().LocalCommits.GetSelectedCommitHash())
},
func() {
self.c.Contexts().LocalCommits.SetSelection(0)
},
)
}
func (self *ModeHelper) ClearFiltering() error {
selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash()
self.c.Modes().Filtering.Reset()
if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF {
self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL)
}
self.c.Refresh(types.RefreshOptions{
Scope: ScopesToRefreshWhenFilteringModeChanges(),
Then: func() error {
return self.changeFiltering(
self.c.Modes().Filtering.Reset,
func() {
// Find the commit that was last selected in filtering mode, and select it again after refreshing
if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) {
// If we couldn't find it (either because no commit was selected
@@ -200,12 +224,62 @@ func (self *ModeHelper) ClearFiltering() error {
// before we entered filtering
self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash())
}
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
return nil
},
)
}
// changeFiltering applies a change to the filtering mode: setFilter mutates the
// mode, then the views whose contents depend on the filter are reloaded, and
// selectCommit puts the selection where it belongs in the reloaded commit list.
//
// Reloading the commit list can take seconds in a big repo, so it happens on a
// worker with a waiting status. Everything the user can see of the change waits
// for it: the screen mode, the focused panel and the reloaded lists all land in
// the same frame, from the refresh's Then, rather than framing an unfiltered
// list as if it were the filtered one. Until then the pre-change state stays on
// screen, and it stays consistent, because the only thing that has changed
// behind it is the filter that the reload is in the middle of applying. The one
// thing that can't wait is the mode indicator in the information panel: the
// filter has to be set before the reload can use it, so the indicator leads the
// lists by however long the reload takes.
//
// Input is blocked for the duration: the keys the user presses arrive after the
// change, which is where they meant them to go, and it keeps a second filter
// change from racing this one — they would both refresh with whichever filter
// happened to be set when their git commands ran.
func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) error {
setFilter()
filtering := self.c.Modes().Filtering.Active()
message := lo.Ternary(filtering, self.c.Tr.ApplyingFilterStatus, self.c.Tr.RemovingFilterStatus)
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{Message: message}, func(gocui.Task) error {
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: ScopesToRefreshWhenFilteringModeChanges(),
BatchUIUpdates: true,
Then: func() error {
repoState := self.c.State().GetRepoState()
if filtering {
if repoState.GetScreenMode() == types.SCREEN_NORMAL {
repoState.SetScreenMode(types.SCREEN_HALF)
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
} else if repoState.GetScreenMode() == types.SCREEN_HALF {
repoState.SetScreenMode(types.SCREEN_NORMAL)
}
selectCommit()
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
// The list we just selected in has nothing to do with the one
// that was showing, so wherever it was scrolled to says nothing
// about where the selection now is. PostRefreshUpdate leaves the
// scroll position alone, so ask for it separately.
self.c.Contexts().LocalCommits.FocusLine(true)
return nil
},
})
return nil
})
return nil
}
// Stashes really only need to be refreshed when filtering by path, not by author, but it's too much
@@ -219,6 +293,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView {
}
}
func (self *ModeHelper) SetSuppressRebasingMode(value bool) {
self.suppressRebasingMode = value
func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) {
self.suppressWorkingTreeStateMode = value
}
+43 -26
View File
@@ -223,10 +223,12 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background,
}
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git()
})
}) {
return
}
if options.BatchUIUpdates {
env.batch = &refreshBounceBatch{}
}
@@ -321,11 +323,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
var capturedCommits capturedCommitState
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommits = self.captureCommitsState()
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
})
}) {
return
}
refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
})
@@ -355,35 +359,43 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// if we've asked specifically for rebase commits and not those other things
var rebaseHashPool *utils.StringPool
var rebaseCommits []*models.Commit
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
})
}) {
return
}
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
}
if scopeSet.Includes(types.SUB_COMMITS) {
var capturedSubCommits capturedSubCommitState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedSubCommits = self.captureSubCommitState()
})
}) {
return
}
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
}
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState()
})
}) {
return
}
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
}
fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) {
var capturedFiles capturedFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedFiles = self.captureFilesState()
})
}) {
return
}
fileWg.Add(1)
refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
@@ -393,9 +405,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.STASH) {
var stashFilterPath string
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath()
})
}) {
return
}
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
}
@@ -408,9 +422,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// needs it to keep the remote-branches selection valid, and reading
// the Remotes context off the UI thread races its render.
var prevSelectedRemote *models.Remote
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
})
}) {
return
}
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
@@ -817,6 +833,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.c.Model().BisectInfo = bisectInfo
self.c.Model().Commits = commits
self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != ""
self.RefreshAuthors(commits)
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
if checkedOutRef != nil {
@@ -1247,21 +1264,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// waiting for a callback that only it can run), and capturing inline also
// guarantees the snapshot reflects the state at the moment Refresh was called,
// before the calling handler regains control and can mutate it.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
//
// It returns false when fn didn't run because the app is shutting down, in
// which case the caller must abandon the refresh rather than compute from a
// snapshot that was never taken.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool {
if !calledFromWorker {
fn()
return
return true
}
wrapped := func() error {
fn()
return nil
}
if background {
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped)
} else {
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil
}
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
}
// capturedFilesState holds the files refresh's context/model inputs, gathered
@@ -1304,7 +1320,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// process working directory, which may already point at another
// repo if the user switched while this refresh was in flight.
hasConflicts, err := mergeconflicts.FileHasConflictMarkers(
filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path))
filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path),
file.ConflictMarkerSize)
if err != nil {
self.c.Log.Error(err)
} else if !hasConflicts {
+33 -11
View File
@@ -54,7 +54,10 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
if err != nil {
return err
}
self.c.State().GetRepoPathStack().Push(wd)
self.c.State().GetRepoPathStack().Push(types.RepoLocation{
Path: wd,
GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
})
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
@@ -164,7 +167,7 @@ func (self *ReposHelper) SwitchToParentRepo() error {
if self.switchRefusedBecauseBusy() {
return nil
}
return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
@@ -189,23 +192,41 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool {
return false
}
// switchTo switches lazygit to the repository (or worktree) at the given path.
// It runs synchronously on the UI thread: the switch swaps gui.State (in
// resetState) and reassigns gui.git and the process cwd, all of which the UI
// thread also reads, so doing it here rather than on a worker avoids racing
// those reads. The heavy data loading is still dispatched asynchronously by the
// refresh that onNewRepo kicks off.
// switchTo switches lazygit to the repository (or worktree) at the given path,
// which git is expected to find from that path alone. That's true of every repo
// we switch to without having been there before.
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
env.UnsetGitLocationEnvVars()
return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey)
}
// switchToLocation switches lazygit to the repository (or worktree) at the
// given location. It runs synchronously on the UI thread: the switch swaps
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
// which the UI thread also reads, so doing it here rather than on a worker
// avoids racing those reads. The heavy data loading is still dispatched
// asynchronously by the refresh that onNewRepo kicks off.
//
// Everything from here on has to find the repo the way git does, from the
// directory we're about to change to, so the location's environment goes into
// the process env before we do. Usually that just clears whatever the repo
// we're leaving needed, but going back to a repo whose git dir isn't in its
// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the
// reason we remember the environment at all: nothing in the path leads to its
// git dir. On failure we put back what the repo we're staying in needs.
func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error {
originalPath, err := os.Getwd()
if err != nil {
return nil
}
originalGitLocationEnvVars := env.GetGitLocationEnvVars()
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
env.SetGitLocationEnvVars(location.GitLocationEnvVars)
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if err := os.Chdir(location.Path); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if os.IsNotExist(err) {
return errors.New(errMsg)
}
@@ -213,6 +234,7 @@ func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.C
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if err := os.Chdir(originalPath); err != nil {
return err
}
+64 -16
View File
@@ -743,7 +743,10 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
HandleConfirm: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.SquashingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
})
@@ -767,7 +770,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.FixingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
})
@@ -780,7 +786,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.FixingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
})
@@ -891,7 +900,10 @@ func (self *LocalCommitsController) handleReword(summary string, description str
self.c.Tr.RewordingStatus, nil, nil)
}
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RewordingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
if err != nil {
return err
@@ -977,7 +989,10 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
if !isMerge {
self.selectRebaseResultCommit(startIdx)
}
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.DroppingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DropCommit)
if isMerge {
return self.dropMergeCommit(commits, startIdx)
@@ -1002,7 +1017,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits
if !commits[endIdx].IsMerge() {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{BatchUIUpdates: true})
@@ -1024,7 +1042,10 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error {
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
commitsToEdit []*models.Commit,
) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
@@ -1164,7 +1185,10 @@ func (self *LocalCommitsController) move(
}
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.MovingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if offset > 0 {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
} else {
@@ -1209,7 +1233,10 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
selectedIdx := self.context().GetView().SelectedLineIdx()
handleCommit = func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
@@ -1271,7 +1298,10 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en
}
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
return err
@@ -1287,7 +1317,10 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e
Title: self.c.Tr.SetAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
return err
@@ -1307,7 +1340,10 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start,
Title: self.c.Tr.AddCoAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.AmendingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
return err
@@ -1341,7 +1377,10 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RevertingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if mustStash {
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
return err
@@ -1392,7 +1431,10 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.CreatingFixupCommitStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
return err
}
@@ -1500,7 +1542,10 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.CreatingFixupCommitStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
return err
}
@@ -1561,7 +1606,10 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba
// up by that many rows to stay on the same commit. Compute the target as an
// absolute index now, on the current list.
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.SquashingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
@@ -230,9 +230,8 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
self.c.Helpers().PatchBuilding.Escape()
return nil
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})
+62 -36
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"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/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
@@ -120,33 +121,29 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err
Title: self.c.Tr.StashApply,
Prompt: self.c.Tr.SureApplyStashEntry,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.ApplyStash)
err := self.c.Git().Stash.Apply(stashEntry.Index)
self.postStashRefresh()
if err != nil {
return err
}
if self.c.UserConfig().Gui.SwitchToFilesAfterStashApply {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
return nil
return self.c.WithWaitingStatusBlockingInput(
types.WaitingStatusOpts{Message: self.c.Tr.ApplyingStashStatus},
func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ApplyStash)
err := self.c.Git().Stash.Apply(stashEntry.Index)
self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply)
return err
})
},
})
}
func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error {
pop := func() error {
self.c.LogAction(self.c.Tr.Actions.PopStash)
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false)
err := self.c.Git().Stash.Pop(stashEntry.Index)
self.postStashRefresh()
if err != nil {
return err
}
if self.c.UserConfig().Gui.SwitchToFilesAfterStashPop {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
return nil
return self.c.WithWaitingStatusBlockingInput(
types.WaitingStatusOpts{Message: self.c.Tr.PoppingStashStatus},
func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.PopStash)
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false)
err := self.c.Git().Stash.Pop(stashEntry.Index)
self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop)
return err
})
}
if self.c.UserConfig().Gui.SkipStashWarning {
@@ -175,31 +172,60 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry)
// iteration lets the workers race and an earlier, stale result can
// land last. The indices are captured up front and we drop
// highest-first, so the remaining lower indices stay valid without
// an intervening refresh. Block input until the refresh has
// landed, so that dropping the next entry in quick succession
// (confirming and pressing the key again right away) sees the
// refreshed list and not the stale, pre-drop indices.
defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
// an intervening refresh.
var dropErr error
for i := len(stashEntries) - 1; i >= 0; i-- {
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false)
if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil {
return err
if dropErr = self.c.Git().Stash.Drop(stashEntries[i].Index); dropErr != nil {
break
}
}
self.context().CollapseRangeSelectionToTop()
return nil
// Block input until the refresh has landed, so that dropping the
// next entry in quick succession (confirming and pressing the key
// again right away) sees the refreshed list and not the stale,
// pre-drop indices.
self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH},
Then: func() error {
// Collapse the range selection from here, so that it lands
// in the same frame as the shortened list. The refresh has
// painted the list by the time Then runs, so the new
// selection needs a focus update of its own.
if dropErr == nil {
self.context().CollapseRangeSelectionToTop()
self.context().HandleFocus(types.OnFocusOpts{})
}
return nil
},
})
return dropErr
},
})
return nil
}
func (self *StashController) postStashRefresh() {
// Block input until the refresh has landed: popping shifts the indices of
// the remaining stash entries, and acting on the next entry in quick
// succession (confirming the popup and pressing the key again right away)
// must see the refreshed list, or it would target the wrong stash.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
// postStashRefresh refreshes the panels that applying or popping a stash
// affects, moving the focus to the files panel if switchToFiles is set.
//
// Call it from the worker that ran the stash command, from inside a
// WithWaitingStatusBlockingInput: popping shifts the indices of the remaining
// stash entries, so acting on the next entry in quick succession (confirming
// the popup and pressing the key again right away) has to be held off until
// the refreshed list is in place, or it would target the wrong stash.
func (self *StashController) postStashRefresh(switchToFiles bool) {
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
Scope: []types.RefreshableView{types.STASH, types.FILES},
Then: func() error {
// Switch panels from here, so that the focus change lands in the
// same frame as the refreshed panel contents.
if switchToFiles {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
return nil
},
})
}
func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error {
+1 -1
View File
@@ -51,7 +51,7 @@ func (self *FileNode) GetHasInlineMergeConflicts() bool {
if !file.HasInlineMergeConflicts {
return false
}
hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path)
hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path, file.ConflictMarkerSize)
return hasConflicts
})
}
+12 -9
View File
@@ -94,9 +94,9 @@ type Gui struct {
Mutexes types.Mutexes
// when you enter into a submodule we'll append the superproject's path to this array
// so that you can return to the superproject
RepoPathStack *utils.StringStack
// when you enter into a submodule we'll append the superproject's location to
// this array so that you can return to the superproject
RepoPathStack *utils.Stack[types.RepoLocation]
// this tells us whether our views have been initially set up
ViewsSetup bool
@@ -158,7 +158,7 @@ type StateAccessor struct {
var _ types.IStateAccessor = new(StateAccessor)
func (self *StateAccessor) GetRepoPathStack() *utils.StringStack {
func (self *StateAccessor) GetRepoPathStack() *utils.Stack[types.RepoLocation] {
return self.gui.RepoPathStack
}
@@ -340,8 +340,10 @@ func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types
}
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error {
var err error
gui.git, err = commands.NewGitCommand(
// Don't assign to gui.git until we know we have one: this also runs when
// switching repos, and leaving the field nil would take down the repo we
// were in before, which is where the error puts us back.
git, err := commands.NewGitCommand(
gui.Common,
gui.gitVersion,
gui.os,
@@ -351,6 +353,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
if err != nil {
return err
}
gui.git = git
err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles())
if err != nil {
@@ -796,7 +799,7 @@ func NewGui(
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.StringStack{},
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
@@ -832,8 +835,8 @@ func NewGui(
return nil
},
func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) },
func(message string, f func(gocui.Task) error) {
gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f)
func(opts types.WaitingStatusOpts, f func(gocui.Task) error) {
gui.helpers.AppStatus.WithWaitingStatusBlockingInput(opts, f)
},
func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) },
func() string { return gui.Views.Prompt.TextArea.GetContent() },
+1 -1
View File
@@ -82,7 +82,7 @@ func (self *GuiDriver) WaitUntilIdle() {
}
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
_ = self.gui.g.OnUIThreadAndWait(f)
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
+59 -22
View File
@@ -2,7 +2,6 @@ package mergeconflicts
import (
"bufio"
"bytes"
"io"
"os"
"strings"
@@ -22,7 +21,23 @@ const (
NOT_A_MARKER
)
func findConflicts(content string) []*mergeConflict {
// The number of characters a conflict marker consists of, unless the file's
// conflict-marker-size gitattribute says otherwise.
const defaultConflictMarkerSize = 7
// The marker size that everything in here takes is the conflict-marker-size
// gitattribute of the file being examined, which is 0 for a file that doesn't
// have that attribute. Git falls back to its default size in that case, so we
// do the same.
func effectiveMarkerSize(markerSize int) int {
if markerSize < 1 {
return defaultConflictMarkerSize
}
return markerSize
}
func findConflicts(content string, markerSize int) []*mergeConflict {
conflicts := make([]*mergeConflict, 0)
if content == "" {
@@ -31,7 +46,7 @@ func findConflicts(content string) []*mergeConflict {
var newConflict *mergeConflict
for i, line := range utils.SplitLines(content) {
switch determineLineType(line) {
switch determineLineType(line, markerSize) {
case START:
newConflict = &mergeConflict{start: i, ancestor: -1}
case ANCESTOR:
@@ -57,35 +72,59 @@ func findConflicts(content string) []*mergeConflict {
return conflicts
}
var (
CONFLICT_START = "<<<<<<< "
CONFLICT_END = ">>>>>>> "
CONFLICT_START_BYTES = []byte(CONFLICT_START)
CONFLICT_END_BYTES = []byte(CONFLICT_END)
)
func determineLineType(line string, markerSize int) LineType {
markerSize = effectiveMarkerSize(markerSize)
func determineLineType(line string) LineType {
// TODO: find out whether we ever actually get this prefix
trimmedLine := strings.TrimPrefix(line, "++")
switch {
case strings.HasPrefix(trimmedLine, CONFLICT_START):
case isConflictMarker(trimmedLine, '<', markerSize):
return START
case strings.HasPrefix(trimmedLine, "||||||| "):
case isConflictMarker(trimmedLine, '|', markerSize):
return ANCESTOR
case trimmedLine == "=======":
case isTargetMarker(trimmedLine, markerSize):
return TARGET
case strings.HasPrefix(trimmedLine, CONFLICT_END):
case isConflictMarker(trimmedLine, '>', markerSize):
return END
default:
return NOT_A_MARKER
}
}
// Tells us whether the line begins with markerSize repetitions of markerChar.
func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) bool {
if len(line) < markerSize {
return false
}
for i := range markerSize {
if line[i] != markerChar {
return false
}
}
return true
}
// A start, ancestor or end marker is followed by a space and a label, e.g.
// "<<<<<<< HEAD". The label can be missing though, in which case git doesn't
// write the space either; `git checkout -m` with the diff3 conflict style does
// that for the ancestor marker, for example.
func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool {
return hasMarkerPrefix(line, markerChar, markerSize) &&
(len(line) == markerSize || line[markerSize] == ' ')
}
// The marker separating the two sides of a conflict never has a label after it.
func isTargetMarker(line string, markerSize int) bool {
return hasMarkerPrefix(line, '=', markerSize) && len(line) == markerSize
}
// tells us whether a file actually has inline merge conflicts. We need to run this
// because git will continue showing a status of 'UU' even after the conflicts have
// been resolved in the user's editor
func FileHasConflictMarkers(path string) (bool, error) {
func FileHasConflictMarkers(path string, markerSize int) (bool, error) {
file, err := os.Open(path)
if err != nil {
return false, err
@@ -93,22 +132,20 @@ func FileHasConflictMarkers(path string) (bool, error) {
defer file.Close()
return fileHasConflictMarkersAux(file)
return fileHasConflictMarkersAux(file, markerSize)
}
// Efficiently scans through a file looking for merge conflict markers. Returns true if it does
func fileHasConflictMarkersAux(file io.Reader) (bool, error) {
func fileHasConflictMarkersAux(file io.Reader, markerSize int) (bool, error) {
markerSize = effectiveMarkerSize(markerSize)
scanner := bufio.NewScanner(file)
scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize))
for scanner.Scan() {
line := scanner.Bytes()
// only searching for start/end markers because the others are more ambiguous
if bytes.HasPrefix(line, CONFLICT_START_BYTES) {
return true, nil
}
if bytes.HasPrefix(line, CONFLICT_END_BYTES) {
if isConflictMarker(line, '<', markerSize) || isConflictMarker(line, '>', markerSize) {
return true, nil
}
}
+88 -7
View File
@@ -8,9 +8,12 @@ import (
)
func TestDetermineLineType(t *testing.T) {
// A markerSize of 0 means the file has no conflict-marker-size gitattribute,
// so git's default size applies.
type scenario struct {
line string
expected LineType
line string
markerSize int
expected LineType
}
scenarios := []scenario{
@@ -54,17 +57,75 @@ func TestDetermineLineType(t *testing.T) {
line: "||||||| adf33b9",
expected: ANCESTOR,
},
{
line: "<<<<<<<<",
expected: NOT_A_MARKER,
},
// Markers without a label
{
line: "<<<<<<<",
expected: START,
},
{
line: "|||||||",
expected: ANCESTOR,
},
{
line: ">>>>>>>",
expected: END,
},
{
line: strings.Repeat("<", 32) + " HEAD",
markerSize: 32,
expected: START,
},
{
line: strings.Repeat("|", 32) + " adf33b9",
markerSize: 32,
expected: ANCESTOR,
},
{
line: strings.Repeat("=", 32),
markerSize: 32,
expected: TARGET,
},
{
line: strings.Repeat(">", 32) + " blah",
markerSize: 32,
expected: END,
},
// A file gets a bigger marker size precisely because its regular content
// tends to contain marker-looking lines, so lines with the default size
// must not be mistaken for markers
{
line: "<<<<<<< HEAD",
markerSize: 32,
expected: NOT_A_MARKER,
},
{
line: "=======",
markerSize: 32,
expected: NOT_A_MARKER,
},
{
line: strings.Repeat("=", 33),
markerSize: 32,
expected: NOT_A_MARKER,
},
}
for _, s := range scenarios {
assert.EqualValues(t, s.expected, determineLineType(s.line))
assert.EqualValues(t, s.expected, determineLineType(s.line, s.markerSize), s.line)
}
}
func TestFindConflictsAux(t *testing.T) {
// A markerSize of 0 means the file has no conflict-marker-size gitattribute,
// so git's default size applies.
type scenario struct {
content string
expected bool
content string
markerSize int
expected bool
}
scenarios := []scenario{
@@ -88,16 +149,36 @@ func TestFindConflictsAux(t *testing.T) {
content: " <<<<<<< ",
expected: false,
},
{
content: ">>>>>>>",
expected: true,
},
{
content: "a\nb\nc\n<<<<<<< ",
expected: true,
},
{
content: "a\nb\nc\n" + strings.Repeat("<", 32) + " HEAD",
markerSize: 32,
expected: true,
},
{
content: "a\nb\nc\n" + strings.Repeat(">", 32) + " blah",
markerSize: 32,
expected: true,
},
// Marker-looking lines of the default size are the file's regular content
{
content: "a\nb\nc\n<<<<<<< HEAD\n=======\n>>>>>>> blah",
markerSize: 32,
expected: false,
},
}
for _, s := range scenarios {
reader := strings.NewReader(s.content)
result, err := fileHasConflictMarkersAux(reader)
result, err := fileHasConflictMarkersAux(reader, s.markerSize)
assert.NoError(t, err)
assert.EqualValues(t, s.expected, result)
assert.EqualValues(t, s.expected, result, s.content)
}
}
+13 -4
View File
@@ -12,6 +12,9 @@ type State struct {
// path of the file with the conflicts
path string
// the file's conflict-marker-size gitattribute, or 0 if it doesn't have one
markerSize int
// This is a stack of the file content. It is used to undo changes.
// The last item is the current file content.
contents []string
@@ -74,12 +77,13 @@ func (s *State) currentConflict() *mergeConflict {
}
// this is for starting a new merge conflict session
func (s *State) SetContent(content string, path string) {
if content == s.GetContent() && path == s.path {
func (s *State) SetContent(content string, path string, markerSize int) {
if content == s.GetContent() && path == s.path && markerSize == s.markerSize {
return
}
s.path = path
s.markerSize = markerSize
s.contents = []string{}
s.PushContent(content)
}
@@ -88,7 +92,7 @@ func (s *State) SetContent(content string, path string) {
// state
func (s *State) PushContent(content string) {
s.contents = append(s.contents, content)
s.setConflicts(findConflicts(content))
s.setConflicts(findConflicts(content, s.markerSize))
}
func (s *State) GetContent() string {
@@ -103,6 +107,10 @@ func (s *State) GetPath() string {
return s.path
}
func (s *State) GetMarkerSize() int {
return s.markerSize
}
func (s *State) Undo() bool {
if len(s.contents) <= 1 {
return false
@@ -112,7 +120,7 @@ func (s *State) Undo() bool {
newContent := s.GetContent()
// We could be storing the old conflicts and selected index on a stack too.
s.setConflicts(findConflicts(newContent))
s.setConflicts(findConflicts(newContent, s.markerSize))
return true
}
@@ -147,6 +155,7 @@ func (s *State) AllConflictsResolved() bool {
func (s *State) Reset() {
s.contents = []string{}
s.path = ""
s.markerSize = 0
}
// we're not resetting selectedIndex here because the user typically would want
+1 -1
View File
@@ -116,7 +116,7 @@ baz
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.EqualValues(t, s.expected, findConflicts(s.content))
assert.EqualValues(t, s.expected, findConflicts(s.content, defaultConflictMarkerSize))
})
}
}
+4 -4
View File
@@ -19,7 +19,7 @@ type PopupHandler struct {
currentContextFn func() types.Context
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error)
withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error)
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
@@ -35,7 +35,7 @@ func NewPopupHandler(
currentContextFn func() types.Context,
createMenuFn func(types.CreateMenuOptions) error,
withWaitingStatusFn func(message string, f func(gocui.Task) error),
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error),
withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error),
toastFn func(message string, kind types.ToastKind),
getPromptInputFn func() string,
inDemo func() bool,
@@ -76,8 +76,8 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e
return nil
}
func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error {
self.withWaitingStatusBlockingInputFn(message, f)
func (self *PopupHandler) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) error {
self.withWaitingStatusBlockingInputFn(opts, f)
return nil
}
+3 -6
View File
@@ -80,9 +80,8 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
return nil
})
}
@@ -97,10 +96,9 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
})
}
@@ -115,10 +113,9 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
})
}
+26 -2
View File
@@ -171,7 +171,7 @@ type IPopupHandler interface {
// Shows a popup prompting the user for input.
Prompt(opts PromptOpts)
WithWaitingStatus(message string, f func(gocui.Task) error) error
WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error
WithWaitingStatusBlockingInput(opts WaitingStatusOpts, f func(gocui.Task) error) error
Menu(opts CreateMenuOptions) error
Toast(message string)
ErrorToast(message string)
@@ -179,6 +179,20 @@ type IPopupHandler interface {
GetPromptInput() string
}
type WaitingStatusOpts struct {
// The message shown alongside the spinner while the operation runs.
Message string
// When set, the working tree state mode (the yellow
// "Rebasing"/"Merging"/"Cherry-picking"/"Reverting" indicator, along with
// its abort button) stays hidden until the operation is done. Set it for
// operations that drive such a state themselves: the state they leave on
// disk while they run is transient, so surfacing it would flash the
// indicator on and offer to abort a sequence that lazygit is in the middle
// of running.
HideWorkingTreeState bool
}
type ToastKind int
const (
@@ -349,6 +363,7 @@ type Model struct {
BisectInfo *git_commands.BisectInfo
WorkingTreeStateAtLastCommitRefresh models.WorkingTreeState
CommitsWereFilteredAtLastRefresh bool
RemoteBranches []*models.RemoteBranch
Tags []*models.Tag
@@ -388,8 +403,17 @@ type HasUrn interface {
URN() string
}
// RepoLocation is everything it takes to open a repo again: the directory to
// change to, plus the environment telling git where the repo is for the repos
// git can't find from that directory (see RepoPaths.GitLocationEnvVars), which
// is empty for all the others.
type RepoLocation struct {
Path string
GitLocationEnvVars []string
}
type IStateAccessor interface {
GetRepoPathStack() *utils.StringStack
GetRepoPathStack() *utils.Stack[RepoLocation]
GetRepoState() IRepoStateAccessor
GetDiffRendererConfigManager() *config.DiffRendererConfigManager
// tells us whether we're currently updating lazygit
+13 -1
View File
@@ -441,6 +441,11 @@ type TranslationSet struct {
ResettingStatus string
CreatingFixupCommitStatus string
MovingCommitsToNewBranchStatus string
ApplyingFilterStatus string
RemovingFilterStatus string
StashingStatus string
ApplyingStashStatus string
PoppingStashStatus string
CommitFiles string
SubCommitsDynamicTitle string
CommitFilesDynamicTitle string
@@ -459,6 +464,7 @@ type TranslationSet struct {
DisabledForGPG string
CreateRepo string
BareRepo string
BareRepoNotSupported string
InitialBranch string
NoRecentRepositories string
IncorrectNotARepository string
@@ -1598,6 +1604,11 @@ func EnglishTranslationSet() *TranslationSet {
ResettingStatus: "Resetting",
CreatingFixupCommitStatus: "Creating fixup commit",
MovingCommitsToNewBranchStatus: "Moving commits to new branch",
ApplyingFilterStatus: "Applying filter",
RemovingFilterStatus: "Removing filter",
StashingStatus: "Stashing",
ApplyingStashStatus: "Applying stash",
PoppingStashStatus: "Popping stash",
CommitFiles: "Commit files",
SubCommitsDynamicTitle: "Commits (%s)",
CommitFilesDynamicTitle: "Diff files (%s)",
@@ -1615,7 +1626,8 @@ func EnglishTranslationSet() *TranslationSet {
DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!",
DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.",
CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ",
BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ",
BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ",
BareRepoNotSupported: "Lazygit does not support bare repos.",
InitialBranch: "Branch name? (leave empty for git's default): ",
NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.",
IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.",
+24 -6
View File
@@ -1,6 +1,7 @@
package components
import (
"errors"
"fmt"
"os"
"os/exec"
@@ -159,9 +160,7 @@ func prepareTestDir(
return "", err
}
workingDir := createFixture(test, paths, rootDir)
return workingDir, nil
return createFixture(test, paths, rootDir)
}
func buildLazygit(testArgs RunTestArgs) error {
@@ -182,22 +181,41 @@ func buildLazygit(testArgs RunTestArgs) error {
return osCommand.Cmd.New(args).Run()
}
// A failing setup step panics with this so that the remaining steps, which
// would only produce follow-on failures, are skipped.
type fixtureFailure string
// Sets up the fixture for test and returns the working directory to invoke
// lazygit in.
func createFixture(test *IntegrationTest, paths Paths, rootDir string) string {
func createFixture(test *IntegrationTest, paths Paths, rootDir string) (workingDir string, err error) {
// Tests run as parallel subtests, and a panic escaping one of them takes
// down the whole test binary, discarding every other test's result along
// with it. Report a broken fixture as this test's error instead.
defer func() {
panicValue := recover()
if panicValue == nil {
return
}
failure, ok := panicValue.(fixtureFailure)
if !ok {
panic(panicValue)
}
err = errors.New(string(failure))
}()
env := NewTestEnvironment(rootDir)
env = append(env, fmt.Sprintf("%s=%s", PWD, paths.ActualRepo()))
shell := NewShell(
paths.ActualRepo(),
env,
func(errorMsg string) { panic(errorMsg) },
func(errorMsg string) { panic(fixtureFailure(errorMsg)) },
)
shell.Init()
test.SetupRepo(shell)
return shell.dir
return shell.dir, nil
}
func testPath(rootdir string) string {
+24
View File
@@ -1,8 +1,11 @@
package components
import (
"os"
"path/filepath"
"testing"
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
@@ -158,6 +161,27 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, "", driver.failureMessage)
}
func TestFailingFixture(t *testing.T) {
test := NewIntegrationTest(NewIntegrationTestArgs{
Description: unitTestDescription,
SetupRepo: func(shell *Shell) {
shell.RunCommand([]string{"git", "checkout", "no-such-branch"})
shell.CreateFile("reached.txt", "")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {},
})
paths := NewPaths(t.TempDir())
assert.NoError(t, os.MkdirAll(paths.ActualRepo(), 0o777))
workingDir, err := createFixture(test, paths, lazycoreUtils.GetLazyRootDirectory())
assert.ErrorContains(t, err, "git checkout no-such-branch")
assert.Empty(t, workingDir)
// the steps following the failing one are skipped
assert.NoFileExists(t, filepath.Join(paths.ActualRepo(), "reached.txt"))
}
func TestGitVersionRestriction(t *testing.T) {
scenarios := []struct {
testName string
@@ -0,0 +1,43 @@
package conflicts
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Doesn't auto-stage an unresolved file whose conflict-marker-size gitattribute makes its markers longer than usual",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shared.SetCustomConflictMarkerSize(shell)
shared.CreateMergeConflictFile(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Common().PretendMergeOrRebaseStartedInLazygit()
t.Views().Files().
IsFocused().
Lines(
Contains("UU file").IsSelected(),
).
// Each refresh checks whether the conflicts are still there
Press(keys.Universal.Refresh).
// They are, so the file doesn't get staged and we don't get asked to
// continue the merge
Lines(
Contains("UU file").IsSelected(),
).
// Once they really are resolved, we do
Tap(func() {
t.Shell().UpdateFile("file", "resolved content")
}).
Press(keys.Universal.Refresh).
Tap(func() {
t.Common().ContinueOnConflictsResolved("merge")
}).
IsEmpty()
},
})
@@ -0,0 +1,40 @@
package conflicts
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Resolves a conflict in a file whose conflict-marker-size gitattribute makes its markers longer than usual",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shared.SetCustomConflictMarkerSize(shell)
shared.CreateMergeConflictFileMultiple(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
startMarker := strings.Repeat("<", shared.CustomConflictMarkerSize)
t.Views().Files().
IsFocused().
Lines(
Contains("UU file").IsSelected(),
).
PressEnter()
t.Views().MergeConflicts().
IsFocused().
SelectedLines(
Contains(startMarker+" HEAD"),
Contains("First Change"),
Contains(strings.Repeat("=", shared.CustomConflictMarkerSize)),
).
PressPrimaryAction().
Content(DoesNotContain(startMarker + " HEAD\nFirst Change"))
},
})
@@ -0,0 +1,34 @@
package misc
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StartInGitDir = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Start lazygit in a repo's .git dir, and have it open the repo",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("blah", "original content\n")
shell.Commit("initial commit")
shell.UpdateFile("blah", "updated content\n")
// this is where lazygit will start
shell.Chdir(".git")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("initial commit"),
)
// we're in the work tree the .git belongs to, not in the .git itself
t.Views().Files().
IsFocused().
Lines(
Contains(" M blah"),
)
},
})
+16
View File
@@ -1,6 +1,8 @@
package shared
import (
"fmt"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
@@ -28,6 +30,20 @@ Second Change
File
`
// A conflict-marker-size that isn't git's default of 7. It's set for file types
// whose regular content tends to contain marker-looking lines, e.g.
// documentation about merging, or test scripts.
const CustomConflictMarkerSize = 32
// Makes git write conflict markers of CustomConflictMarkerSize characters into
// the file that the setups below create conflicts in. Call this before one of
// them.
var SetCustomConflictMarkerSize = func(shell *Shell) {
shell.CreateFileAndAdd(".gitattributes",
fmt.Sprintf("file conflict-marker-size=%d\n", CustomConflictMarkerSize)).
Commit("set a custom conflict marker size")
}
// prepares us for a rebase/merge that has conflicts
var MergeConflictsSetup = func(shell *Shell) {
shell.
+1 -1
View File
@@ -29,7 +29,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Status().Content(Contains("repo"))
}
assertInSubmodule := func() {
t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)"))
t.Views().Status().Content(Contains("my_submodule_path"))
}
assertInParentRepo()
@@ -0,0 +1,72 @@
package submodule
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// Entering a submodule and escaping back out again, in a repo that git can only
// find because we were told where it is (--git-dir/--work-tree). Entering the
// submodule has to leave that behind, since it says where the superproject is,
// so coming back out has to bring it along again.
var EnterFromDotfileBareRepo = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Enter a submodule of a dotfile bare repo and escape back out again",
ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare", "--work-tree={{.actualPath}}/repo"},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// we're going to have a directory structure like this:
// project
// - .bare (the git dir)
// - repo (the work tree, with no .git of its own)
// - my_submodule_name (the submodule's remote)
//
// The work tree is called 'repo' because that's the directory that all
// lazygit tests start in
// make a repo for the submodule to be cloned from, using the .git dir
// that every test starts with
shell.EmptyCommit("initial submodule commit")
shell.Clone("my_submodule_name")
// now turn the test repo into a dotfile-style bare repo
shell.DeleteFile(".git")
shell.RunCommand([]string{"git", "init", "--bare", "../.bare"})
gitInBareRepo := []string{"git", "--git-dir=../.bare", "--work-tree=."}
shell.RunCommand(append(gitInBareRepo, "checkout", "-b", "mybranch"))
shell.CreateFile("blah", "blah\n")
shell.RunCommand(append(gitInBareRepo, "add", "blah"))
shell.RunCommand(append(gitInBareRepo, "commit", "-m", "initial commit"))
shell.RunCommand(append(gitInBareRepo, "-c", "protocol.file.allow=always", "submodule",
"add", "--name", "my_submodule_name", "../my_submodule_name", "my_submodule_path"))
shell.RunCommand(append(gitInBareRepo, "commit", "-m", "add submodule"))
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
assertInParentRepo := func() {
t.Views().Status().Content(Contains("repo"))
t.Views().Commits().Lines(
Contains("add submodule"),
Contains("initial commit"),
)
}
assertInParentRepo()
t.Views().Submodules().Focus().
Lines(
Contains("my_submodule_name").IsSelected(),
).
PressEnter()
t.Views().Status().Content(Contains("my_submodule_path"))
t.Views().Commits().Lines(
Contains("initial submodule commit"),
)
t.Views().Files().IsFocused().PressEscape()
assertInParentRepo()
t.Views().Submodules().IsFocused()
},
})
@@ -37,7 +37,7 @@ var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{
// enter the nested submodule
PressEnter()
t.Views().Status().Content(Contains("innerSubPath(innerSubName)"))
t.Views().Status().Content(Contains("innerSubPath"))
t.Views().Commits().ContainsLines(
Contains("initial inner commit"),
)
+1 -1
View File
@@ -31,7 +31,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Status().Content(Contains("repo"))
}
assertInSubmodule := func() {
t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)"))
t.Views().Status().Content(Contains("my_submodule_path"))
}
assertInParentRepo()
+5
View File
@@ -165,6 +165,8 @@ var tests = []*components.IntegrationTest{
config.NegativeRefspec,
config.RemoteNamedStar,
config.SidePanelsInPerRepoConfig,
conflicts.ConflictMarkerSizeNotAutoStaged,
conflicts.ConflictMarkerSizeResolve,
conflicts.ContinuePromptDismissedWhenResolvedExternally,
conflicts.Filter,
conflicts.MergeFileBoth,
@@ -353,6 +355,7 @@ var tests = []*components.IntegrationTest{
misc.DirenvUnloadsOnBlockedEnvrc,
misc.InitialOpen,
misc.RecentReposOnLaunch,
misc.StartInGitDir,
patch_building.Apply,
patch_building.ApplyInReverse,
patch_building.ApplyInReverseWithConflict,
@@ -441,6 +444,7 @@ var tests = []*components.IntegrationTest{
status.LogCmdStatusPanelAllBranchesLog,
submodule.Add,
submodule.Enter,
submodule.EnterFromDotfileBareRepo,
submodule.EnterNested,
submodule.Remove,
submodule.RemoveNested,
@@ -541,6 +545,7 @@ var tests = []*components.IntegrationTest{
worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch,
worktree.RemoveWorktreeFromBranch,
worktree.ResetWindowTabs,
worktree.SeparateWorkTreeConfig,
worktree.SymlinkIntoRepoSubdir,
worktree.WorktreeInRepo,
}
@@ -0,0 +1,70 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// This case is like bare_repo_worktree_config.go, except that lazygit isn't
// told where the git dir is: it is started in the directory containing it, and
// finds it the way git does. The work tree is somewhere else entirely, so git
// can't find its way back from there, and every command we run has to be told
// where the repo is.
var SeparateWorkTreeConfig = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Open lazygit in the git dir of a repo whose work tree is elsewhere, and add a file and commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// we're going to have a directory structure like this:
// project
// - repo (holds the .git dir, and nothing else; lazygit starts here)
// - worktree (holds the files)
//
// 'repo' is the repository/directory that all lazygit tests start in
shell.CreateFileAndAdd("blah", "original content\n")
shell.Commit("initial commit")
// point the repo at a work tree outside of it (core.worktree is
// relative to the .git dir), and fill that work tree from HEAD
shell.CreateDir("../worktree")
shell.SetConfig("core.worktree", "../../worktree")
shell.RunCommand([]string{"git", "reset", "--hard"})
// the copy of the file we committed from is not in the work tree, so
// git no longer knows anything about it
shell.DeleteFile("blah")
shell.UpdateFile("../worktree/blah", "updated content\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("initial commit"),
)
t.Views().Files().
IsFocused().
Lines(
Contains(" M blah"), // shows as modified
).
PressPrimaryAction().
Press(keys.Files.CommitChanges)
t.ExpectPopup().CommitMessagePanel().
Title(Equals("Commit summary")).
Type("Add blah").
Confirm()
t.Views().Files().
IsEmpty()
t.Views().Commits().
Lines(
Contains("Add blah"),
Contains("initial commit"),
)
},
})
+4 -10
View File
@@ -87,7 +87,7 @@ type ViewBufferManager struct {
// of the view happen through this, so that the view is only ever touched on
// the UI thread (where it is also laid out and drawn), never on the task's
// own goroutine.
onUIThread func(f func() error) error
onUIThread func(f func()) error
// if the user flicks through a heap of items, with each one
// spawning a process to render something to the main view,
@@ -126,7 +126,7 @@ func NewViewBufferManager(
onEndOfInput func(),
onNewKey func(),
newGocuiTask func() gocui.Task,
onUIThread func(f func() error) error,
onUIThread func(f func()) error,
) *ViewBufferManager {
return &ViewBufferManager{
Log: log,
@@ -358,10 +358,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
// onEndOfInput reads the view's dimensions (to decide
// whether to scroll) and sets the origin, both of which
// are UI-thread-only, so run it there.
_ = self.onUIThread(func() error {
self.onEndOfInput()
return nil
})
_ = self.onUIThread(self.onEndOfInput)
callThen()
break outer
}
@@ -502,10 +499,7 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error
// must happen after releasing taskIDMutex: it blocks until the UI
// thread runs it, and a NewTask call on the UI thread takes
// taskIDMutex, so holding it here would deadlock.
_ = self.onUIThread(func() error {
self.onNewKey()
return nil
})
_ = self.onUIThread(self.onNewKey)
}
self.waitingMutex.Lock()
+3 -3
View File
@@ -40,7 +40,7 @@ func TestNewCmdTaskInstantStop(t *testing.T) {
onNewKey,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
func(f func()) error { f(); return nil },
)
stop := make(chan struct{})
@@ -107,7 +107,7 @@ func TestNewCmdTask(t *testing.T) {
onNewKey,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
func(f func()) error { f(); return nil },
)
stop := make(chan struct{})
@@ -242,7 +242,7 @@ func TestNewCmdTaskRefresh(t *testing.T) {
func() {},
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
func(f func()) error { f(); return nil },
)
stop := make(chan struct{})
+28
View File
@@ -0,0 +1,28 @@
package utils
type Stack[T any] struct {
stack []T
}
func (self *Stack[T]) Push(item T) {
self.stack = append(self.stack, item)
}
func (self *Stack[T]) Pop() T {
if len(self.stack) == 0 {
var zero T
return zero
}
n := len(self.stack) - 1
last := self.stack[n]
self.stack = self.stack[:n]
return last
}
func (self *Stack[T]) IsEmpty() bool {
return len(self.stack) == 0
}
func (self *Stack[T]) Clear() {
self.stack = nil
}
-27
View File
@@ -1,27 +0,0 @@
package utils
type StringStack struct {
stack []string
}
func (self *StringStack) Push(s string) {
self.stack = append(self.stack, s)
}
func (self *StringStack) Pop() string {
if len(self.stack) == 0 {
return ""
}
n := len(self.stack) - 1
last := self.stack[n]
self.stack = self.stack[:n]
return last
}
func (self *StringStack) IsEmpty() bool {
return len(self.stack) == 0
}
func (self *StringStack) Clear() {
self.stack = []string{}
}
-14
View File
@@ -2,16 +2,6 @@
echo "Running integration tests with $(git --version)"
# This is ugly, but older versions of git don't support the GIT_CONFIG_GLOBAL
# env var; the only way to run tests for these old versions is to copy our test
# config file to the actual global location. Move an existing file out of the
# way so that we can restore it at the end.
if test -f ~/.gitconfig; then
mv ~/.gitconfig ~/.gitconfig.lazygit.bak
fi
cp test/global_git_config ~/.gitconfig
# if the LAZYGIT_GOCOVERDIR env var is set, we'll capture code coverage data
if [ -n "$LAZYGIT_GOCOVERDIR" ]; then
# Go expects us to either be running the test binary directly or running `go test`, but because
@@ -33,10 +23,6 @@ else
EXITCODE=$?
fi
if test -f ~/.gitconfig.lazygit.bak; then
mv ~/.gitconfig.lazygit.bak ~/.gitconfig
fi
# If per-test timings were collected (LAZYGIT_TEST_TIMING points at the file the
# harness appends to), print them sorted by slowest first so they show up in the
# CI log.
+9
View File
@@ -8,3 +8,12 @@
allow = always
[commit]
gpgSign = false
[maintenance]
# Every `git commit` forks `git maintenance run --auto --detach`. Since git
# 2.54 that repacks as soon as two objects share the objects/17 fanout
# directory, which happens readily in a fixture repo, and `git repack -d`
# prunes loose objects while the next fixture command -- or lazygit itself --
# is still working in the same repo. That surfaces as
# "error: invalid object <hash> for 'file09.txt'" / "Error building trees".
# Tests must never race a background repack.
auto = false
+97 -63
View File
@@ -62,6 +62,7 @@ func emojiCode() map[string]string {
":Leo:": "\u264c",
":Libra:": "\u264e",
":Mrs._Claus:": "\U0001f936",
":Mx_Claus:": "\U0001f9d1\u200d\U0001f384",
":NEW_button:": "\U0001f195",
":NG_button:": "\U0001f196",
":OK_button:": "\U0001f197",
@@ -220,6 +221,7 @@ func emojiCode() map[string]string {
":bald_man:": "\U0001f468\u200d\U0001f9b2",
":bald_person:": "\U0001f9d1\u200d\U0001f9b2",
":bald_woman:": "\U0001f469\u200d\U0001f9b2",
":ballet_dancer:": "\U0001f9d1\u200d\U0001fa70",
":ballet_shoes:": "\U0001fa70",
":balloon:": "\U0001f388",
":ballot_box:": "\U0001f5f3",
@@ -764,6 +766,7 @@ func emojiCode() map[string]string {
":disappointed_face:": "\U0001f61e",
":disappointed_relieved:": "\U0001f625",
":disguised_face:": "\U0001f978",
":distorted_face:": "\U0001faea",
":divide:": "\u2797",
":dividers:": "\U0001f5c2",
":diving_mask:": "\U0001f93f",
@@ -890,6 +893,7 @@ func emojiCode() map[string]string {
":face_savoring_food:": "\U0001f60b",
":face_screaming_in_fear:": "\U0001f631",
":face_vomiting:": "\U0001f92e",
":face_with_bags_under_eyes:": "\U0001fae9",
":face_with_cowboy_hat:": "\U0001f920",
":face_with_crossed-out_eyes:": "\U0001f635",
":face_with_diagonal_mouth:": "\U0001fae4",
@@ -1018,12 +1022,14 @@ func emojiCode() map[string]string {
":ferry:": "\u26f4\ufe0f",
":field_hockey:": "\U0001f3d1",
":field_hockey_stick_and_ball:": "\U0001f3d1",
":fight_cloud:": "\U0001faef",
":fiji:": "\U0001f1eb\U0001f1ef",
":file_cabinet:": "\U0001f5c4\ufe0f",
":file_folder:": "\U0001f4c1",
":film_frames:": "\U0001f39e\ufe0f",
":film_projector:": "\U0001f4fd\ufe0f",
":film_strip:": "\U0001f39e\ufe0f",
":fingerprint:": "\U0001fac6",
":fingers_crossed:": "\U0001f91e",
":fingers_crossed_tone1:": "\U0001f91e\U0001f3fb",
":fingers_crossed_tone2:": "\U0001f91e\U0001f3fc",
@@ -1251,6 +1257,7 @@ func emojiCode() map[string]string {
":flag-rs:": "\U0001f1f7\U0001f1f8",
":flag-rw:": "\U0001f1f7\U0001f1fc",
":flag-sa:": "\U0001f1f8\U0001f1e6",
":flag-sark:": "\U0001f1e8\U0001f1f6",
":flag-sb:": "\U0001f1f8\U0001f1e7",
":flag-sc:": "\U0001f1f8\U0001f1e8",
":flag-scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f",
@@ -1504,6 +1511,7 @@ func emojiCode() map[string]string {
":flag_Réunion:": "\U0001f1f7\U0001f1ea",
":flag_Samoa:": "\U0001f1fc\U0001f1f8",
":flag_San_Marino:": "\U0001f1f8\U0001f1f2",
":flag_Sark:": "\U0001f1e8\U0001f1f6",
":flag_Saudi_Arabia:": "\U0001f1f8\U0001f1e6",
":flag_Scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f",
":flag_Senegal:": "\U0001f1f8\U0001f1f3",
@@ -1995,6 +2003,7 @@ func emojiCode() map[string]string {
":haircut:": "\U0001f487\u200d\u2640\ufe0f",
":haircut_man:": "\U0001f487\u200d\u2642\ufe0f",
":haircut_woman:": "\U0001f487\u200d\u2640\ufe0f",
":hairy_creature:": "\U0001fac8",
":haiti:": "\U0001f1ed\U0001f1f9",
":hamburger:": "\U0001f354",
":hammer:": "\U0001f528",
@@ -2012,43 +2021,44 @@ func emojiCode() map[string]string {
":hand_splayed_tone5:": "\U0001f590\U0001f3ff",
":hand_with_fingers_splayed:": "\U0001f590",
":hand_with_index_finger_and_thumb_crossed:": "\U0001faf0",
":handbag:": "\U0001f45c",
":handball:": "\U0001f93e",
":handball_person:": "\U0001f93e",
":handshake:": "\U0001f91d",
":hankey:": "\U0001f4a9",
":hash:": "#\ufe0f\u20e3",
":hatched_chick:": "\U0001f425",
":hatching_chick:": "\U0001f423",
":head_bandage:": "\U0001f915",
":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f",
":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f",
":headphone:": "\U0001f3a7",
":headphones:": "\U0001f3a7",
":headstone:": "\U0001faa6",
":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f",
":hear-no-evil_monkey:": "\U0001f649",
":hear_no_evil:": "\U0001f649",
":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2",
":heart:": "\u2764\ufe0f",
":heart_decoration:": "\U0001f49f",
":heart_exclamation:": "\u2763",
":heart_eyes:": "\U0001f60d",
":heart_eyes_cat:": "\U0001f63b",
":heart_hands:": "\U0001faf6",
":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525",
":heart_suit:": "\u2665",
":heart_with_arrow:": "\U0001f498",
":heart_with_ribbon:": "\U0001f49d",
":heartbeat:": "\U0001f493",
":heartpulse:": "\U0001f497",
":hearts:": "\u2665\ufe0f",
":heavy_check_mark:": "\u2714\ufe0f",
":heavy_division_sign:": "\u2797",
":heavy_dollar_sign:": "\U0001f4b2",
":heavy_equals_sign:": "\U0001f7f0",
":heavy_exclamation_mark:": "\u2757",
":heavy_heart_exclamation:": "\u2763\ufe0f",
":handbag:": "\U0001f45c",
":handball:": "\U0001f93e",
":handball_person:": "\U0001f93e",
":handshake:": "\U0001f91d",
":hankey:": "\U0001f4a9",
":harp:": "\U0001fa89",
":hash:": "#\ufe0f\u20e3",
":hatched_chick:": "\U0001f425",
":hatching_chick:": "\U0001f423",
":head_bandage:": "\U0001f915",
":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f",
":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f",
":headphone:": "\U0001f3a7",
":headphones:": "\U0001f3a7",
":headstone:": "\U0001faa6",
":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f",
":hear-no-evil_monkey:": "\U0001f649",
":hear_no_evil:": "\U0001f649",
":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2",
":heart:": "\u2764\ufe0f",
":heart_decoration:": "\U0001f49f",
":heart_exclamation:": "\u2763",
":heart_eyes:": "\U0001f60d",
":heart_eyes_cat:": "\U0001f63b",
":heart_hands:": "\U0001faf6",
":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525",
":heart_suit:": "\u2665",
":heart_with_arrow:": "\U0001f498",
":heart_with_ribbon:": "\U0001f49d",
":heartbeat:": "\U0001f493",
":heartpulse:": "\U0001f497",
":hearts:": "\u2665\ufe0f",
":heavy_check_mark:": "\u2714\ufe0f",
":heavy_division_sign:": "\u2797",
":heavy_dollar_sign:": "\U0001f4b2",
":heavy_equals_sign:": "\U0001f7f0",
":heavy_exclamation_mark:": "\u2757",
":heavy_heart_exclamation:": "\u2763\ufe0f",
":heavy_heart_exclamation_mark_ornament:": "\u2763\ufe0f",
":heavy_minus_sign:": "\u2796",
":heavy_multiplication_x:": "\u2716\ufe0f",
@@ -2237,6 +2247,7 @@ func emojiCode() map[string]string {
":ladder:": "\U0001fa9c",
":lady_beetle:": "\U0001f41e",
":ladybug:": "\U0001f41e",
":landslide:": "\U0001f6d8",
":lantern:": "\U0001f3ee",
":laos:": "\U0001f1f1\U0001f1e6",
":laptop:": "\U0001f4bb",
@@ -2263,6 +2274,7 @@ func emojiCode() map[string]string {
":latvia:": "\U0001f1f1\U0001f1fb",
":laughing:": "\U0001f606",
":leaf_fluttering_in_wind:": "\U0001f343",
":leafless_tree:": "\U0001fabe",
":leafy_green:": "\U0001f96c",
":leaves:": "\U0001f343",
":lebanon:": "\U0001f1f1\U0001f1e7",
@@ -3146,6 +3158,7 @@ func emojiCode() map[string]string {
":orange_heart:": "\U0001f9e1",
":orange_square:": "\U0001f7e7",
":orangutan:": "\U0001f9a7",
":orca:": "\U0001facd",
":orthodox_cross:": "\u2626\ufe0f",
":otter:": "\U0001f9a6",
":outbox_tray:": "\U0001f4e4",
@@ -3702,6 +3715,7 @@ func emojiCode() map[string]string {
":rolling_on_the_floor_laughing:": "\U0001f923",
":romania:": "\U0001f1f7\U0001f1f4",
":rooster:": "\U0001f413",
":root_vegetable:": "\U0001fadc",
":rose:": "\U0001f339",
":rosette:": "\U0001f3f5\ufe0f",
":rotating_light:": "\U0001f6a8",
@@ -3810,6 +3824,7 @@ func emojiCode() map[string]string {
":shopping_trolley:": "\U0001f6d2",
":shortcake:": "\U0001f370",
":shorts:": "\U0001fa73",
":shovel:": "\U0001fa8f",
":shower:": "\U0001f6bf",
":shrimp:": "\U0001f990",
":shrug:": "\U0001f937",
@@ -3938,6 +3953,7 @@ func emojiCode() map[string]string {
":spiral_note_pad:": "\U0001f5d2\ufe0f",
":spiral_notepad:": "\U0001f5d2",
":spiral_shell:": "\U0001f41a",
":splatter:": "\U0001fadf",
":spock-hand:": "\U0001f596",
":sponge:": "\U0001f9fd",
":spoon:": "\U0001f944",
@@ -4129,6 +4145,7 @@ func emojiCode() map[string]string {
":tram_car:": "\U0001f68b",
":transgender_flag:": "\U0001f3f3\ufe0f\u200d\u26a7\ufe0f",
":transgender_symbol:": "\u26a7\ufe0f",
":treasure_chest:": "\U0001fa8e",
":triangular_flag:": "\U0001f6a9",
":triangular_flag_on_post:": "\U0001f6a9",
":triangular_ruler:": "\U0001f4d0",
@@ -4139,6 +4156,7 @@ func emojiCode() map[string]string {
":triumph:": "\U0001f624",
":troll:": "\U0001f9cc",
":trolleybus:": "\U0001f68e",
":trombone:": "\U0001fa8a",
":trophy:": "\U0001f3c6",
":tropical_drink:": "\U0001f379",
":tropical_fish:": "\U0001f420",
@@ -4881,6 +4899,7 @@ func emojiRevCode() map[string][]string {
"\U0001f1e8\U0001f1f3": {":cn:", ":flag_cn:", ":flag_China:"},
"\U0001f1e8\U0001f1f4": {":flag-co:", ":flag_co:", ":colombia:", ":flag_Colombia:"},
"\U0001f1e8\U0001f1f5": {":flag-cp:", ":flag_cp:", ":clipperton_island:", ":flag_Clipperton_Island:"},
"\U0001f1e8\U0001f1f6": {":flag-sark:", ":flag_Sark:"},
"\U0001f1e8\U0001f1f7": {":flag-cr:", ":flag_cr:", ":costa_rica:", ":flag_Costa_Rica:"},
"\U0001f1e8\U0001f1fa": {":cuba:", ":flag-cu:", ":flag_cu:", ":flag_Cuba:"},
"\U0001f1e8\U0001f1fb": {":flag-cv:", ":flag_cv:", ":cape_verde:", ":flag_Cape_Verde:"},
@@ -5246,7 +5265,7 @@ func emojiRevCode() map[string][]string {
"\U0001f381": {":gift:", ":wrapped_gift:"},
"\U0001f382": {":birthday:", ":birthday_cake:"},
"\U0001f383": {":jack-o-lantern:", ":jack_o_lantern:"},
"\U0001f384": {":Christmas_tree:", ":christmas_tree:"},
"\U0001f384": {":christmas_tree:", ":Christmas_tree:"},
"\U0001f385": {":santa:", ":Santa_Claus:"},
"\U0001f385\U0001f3fb": {":santa_tone1:"},
"\U0001f385\U0001f3fc": {":santa_tone2:"},
@@ -5465,7 +5484,7 @@ func emojiRevCode() map[string][]string {
"\U0001f3ec": {":department_store:"},
"\U0001f3ed": {":factory:"},
"\U0001f3ee": {":lantern:", ":izakaya_lantern:", ":red_paper_lantern:"},
"\U0001f3ef": {":Japanese_castle:", ":japanese_castle:"},
"\U0001f3ef": {":japanese_castle:", ":Japanese_castle:"},
"\U0001f3f0": {":castle:", ":european_castle:"},
"\U0001f3f3": {":flag_white:", ":white_flag:"},
"\U0001f3f3\ufe0f": {":waving_white_flag:"},
@@ -5608,7 +5627,7 @@ func emojiRevCode() map[string][]string {
"\U0001f44b\U0001f3fd": {":wave_tone3:"},
"\U0001f44b\U0001f3fe": {":wave_tone4:"},
"\U0001f44b\U0001f3ff": {":wave_tone5:"},
"\U0001f44c": {":OK_hand:", ":ok_hand:"},
"\U0001f44c": {":ok_hand:", ":OK_hand:"},
"\U0001f44c\U0001f3fb": {":ok_hand_tone1:"},
"\U0001f44c\U0001f3fc": {":ok_hand_tone2:"},
"\U0001f44c\U0001f3fd": {":ok_hand_tone3:"},
@@ -6169,7 +6188,7 @@ func emojiRevCode() map[string][]string {
"\U0001f4a1": {":bulb:", ":light_bulb:"},
"\U0001f4a2": {":anger:", ":anger_symbol:"},
"\U0001f4a3": {":bomb:"},
"\U0001f4a4": {":ZZZ:", ":zzz:"},
"\U0001f4a4": {":zzz:", ":ZZZ:"},
"\U0001f4a5": {":boom:", ":collision:"},
"\U0001f4a6": {":sweat_drops:", ":sweat_droplets:"},
"\U0001f4a7": {":droplet:"},
@@ -6466,8 +6485,8 @@ func emojiRevCode() map[string][]string {
"\U0001f5fa": {":map:"},
"\U0001f5fa\ufe0f": {":world_map:"},
"\U0001f5fb": {":mount_fuji:"},
"\U0001f5fc": {":Tokyo_tower:", ":tokyo_tower:"},
"\U0001f5fd": {":Statue_of_Liberty:", ":statue_of_liberty:"},
"\U0001f5fc": {":tokyo_tower:", ":Tokyo_tower:"},
"\U0001f5fd": {":statue_of_liberty:", ":Statue_of_Liberty:"},
"\U0001f5fe": {":japan:", ":map_of_Japan:"},
"\U0001f5ff": {":moai:", ":moyai:"},
"\U0001f600": {":grinning:", ":grinning_face:"},
@@ -6544,7 +6563,7 @@ func emojiRevCode() map[string][]string {
"\U0001f642\u200d\u2195\ufe0f": {":head_shaking_vertically:"},
"\U0001f643": {":upside_down:", ":upside-down_face:", ":upside_down_face:"},
"\U0001f644": {":roll_eyes:", ":rolling_eyes:", ":face_with_rolling_eyes:"},
"\U0001f645": {":person_gesturing_NO:", ":person_gesturing_no:"},
"\U0001f645": {":person_gesturing_no:", ":person_gesturing_NO:"},
"\U0001f645\U0001f3fb": {":person_gesturing_no_tone1:"},
"\U0001f645\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_no_tone1:"},
"\U0001f645\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_no_tone1:"},
@@ -6560,9 +6579,9 @@ func emojiRevCode() map[string][]string {
"\U0001f645\U0001f3ff": {":person_gesturing_no_tone5:"},
"\U0001f645\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_no_tone5:"},
"\U0001f645\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_no_tone5:"},
"\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_NO:", ":woman_gesturing_no:"},
"\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_NO:", ":man_gesturing_no:"},
"\U0001f646": {":ok_person:", ":person_gesturing_OK:", ":person_gesturing_ok:"},
"\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_no:", ":woman_gesturing_NO:"},
"\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_no:", ":man_gesturing_NO:"},
"\U0001f646": {":ok_person:", ":person_gesturing_ok:", ":person_gesturing_OK:"},
"\U0001f646\U0001f3fb": {":person_gesturing_ok_tone1:"},
"\U0001f646\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone1:"},
"\U0001f646\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_ok_tone1:"},
@@ -6578,8 +6597,8 @@ func emojiRevCode() map[string][]string {
"\U0001f646\U0001f3ff": {":person_gesturing_ok_tone5:"},
"\U0001f646\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone5:"},
"\U0001f646\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_ok_tone5:"},
"\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_OK:", ":woman_gesturing_ok:"},
"\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_OK:", ":man_gesturing_ok:"},
"\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_ok:", ":woman_gesturing_OK:"},
"\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_ok:", ":man_gesturing_OK:"},
"\U0001f647": {":bow:", ":person_bowing:"},
"\U0001f647\U0001f3fb": {":person_bowing_tone1:"},
"\U0001f647\U0001f3fb\u200d\u2640\ufe0f": {":woman_bowing_tone1:"},
@@ -6831,6 +6850,7 @@ func emojiRevCode() map[string][]string {
"\U0001f6d5": {":hindu_temple:"},
"\U0001f6d6": {":hut:"},
"\U0001f6d7": {":elevator:"},
"\U0001f6d8": {":landslide:"},
"\U0001f6dc": {":wireless:"},
"\U0001f6dd": {":playground_slide:"},
"\U0001f6de": {":wheel:"},
@@ -7186,7 +7206,7 @@ func emojiRevCode() map[string][]string {
"\U0001f993": {":zebra:", ":zebra_face:"},
"\U0001f994": {":hedgehog:"},
"\U0001f995": {":sauropod:"},
"\U0001f996": {":T-Rex:", ":t-rex:", ":t_rex:"},
"\U0001f996": {":t-rex:", ":T-Rex:", ":t_rex:"},
"\U0001f997": {":cricket:"},
"\U0001f998": {":kangaroo:"},
"\U0001f999": {":llama:"},
@@ -7267,7 +7287,7 @@ func emojiRevCode() map[string][]string {
"\U0001f9d1\u200d\U0001f33e": {":farmer:"},
"\U0001f9d1\u200d\U0001f373": {":cook:"},
"\U0001f9d1\u200d\U0001f37c": {":person_feeding_baby:"},
"\U0001f9d1\u200d\U0001f384": {":mx_claus:"},
"\U0001f9d1\u200d\U0001f384": {":mx_claus:", ":Mx_Claus:"},
"\U0001f9d1\u200d\U0001f393": {":student:"},
"\U0001f9d1\u200d\U0001f3a4": {":singer:"},
"\U0001f9d1\u200d\U0001f3a8": {":artist:"},
@@ -7294,6 +7314,7 @@ func emojiRevCode() map[string][]string {
"\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_adult_child_child:"},
"\U0001f9d1\u200d\U0001f9d2": {":family_adult_child:"},
"\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_child_child:"},
"\U0001f9d1\u200d\U0001fa70": {":ballet_dancer:"},
"\U0001f9d1\u200d\u2695\ufe0f": {":health_worker:"},
"\U0001f9d1\u200d\u2696\ufe0f": {":judge:"},
"\U0001f9d1\u200d\u2708\ufe0f": {":pilot:"},
@@ -7520,6 +7541,10 @@ func emojiRevCode() map[string][]string {
"\U0001fa86": {":nesting_dolls:"},
"\U0001fa87": {":maracas:"},
"\U0001fa88": {":flute:"},
"\U0001fa89": {":harp:"},
"\U0001fa8a": {":trombone:"},
"\U0001fa8e": {":treasure_chest:"},
"\U0001fa8f": {":shovel:"},
"\U0001fa90": {":ringed_planet:"},
"\U0001fa91": {":chair:"},
"\U0001fa92": {":razor:"},
@@ -7566,6 +7591,7 @@ func emojiRevCode() map[string][]string {
"\U0001fabb": {":hyacinth:"},
"\U0001fabc": {":jellyfish:"},
"\U0001fabd": {":wing:"},
"\U0001fabe": {":leafless_tree:"},
"\U0001fabf": {":goose:"},
"\U0001fac0": {":anatomical_heart:"},
"\U0001fac1": {":lungs:"},
@@ -7573,6 +7599,9 @@ func emojiRevCode() map[string][]string {
"\U0001fac3": {":pregnant_man:"},
"\U0001fac4": {":pregnant_person:"},
"\U0001fac5": {":person_with_crown:"},
"\U0001fac6": {":fingerprint:"},
"\U0001fac8": {":hairy_creature:"},
"\U0001facd": {":orca:"},
"\U0001face": {":moose:"},
"\U0001facf": {":donkey:"},
"\U0001fad0": {":blueberries:"},
@@ -7587,6 +7616,8 @@ func emojiRevCode() map[string][]string {
"\U0001fad9": {":jar:"},
"\U0001fada": {":ginger_root:"},
"\U0001fadb": {":pea_pod:"},
"\U0001fadc": {":root_vegetable:"},
"\U0001fadf": {":splatter:"},
"\U0001fae0": {":melting_face:"},
"\U0001fae1": {":saluting_face:"},
"\U0001fae2": {":face_with_open_eyes_and_hand_over_mouth:"},
@@ -7596,6 +7627,9 @@ func emojiRevCode() map[string][]string {
"\U0001fae6": {":biting_lip:"},
"\U0001fae7": {":bubbles:"},
"\U0001fae8": {":shaking_face:"},
"\U0001fae9": {":face_with_bags_under_eyes:"},
"\U0001faea": {":distorted_face:"},
"\U0001faef": {":fight_cloud:"},
"\U0001faf0": {":hand_with_index_finger_and_thumb_crossed:"},
"\U0001faf1": {":rightwards_hand:"},
"\U0001faf2": {":leftwards_hand:"},
@@ -7709,18 +7743,18 @@ func emojiRevCode() map[string][]string {
"\u263a\ufe0f": {":relaxed:"},
"\u2640\ufe0f": {":female_sign:"},
"\u2642\ufe0f": {":male_sign:"},
"\u2648": {":Aries:", ":aries:"},
"\u2649": {":Taurus:", ":taurus:"},
"\u264a": {":Gemini:", ":gemini:"},
"\u264b": {":Cancer:", ":cancer:"},
"\u264c": {":Leo:", ":leo:"},
"\u264d": {":Virgo:", ":virgo:"},
"\u264e": {":Libra:", ":libra:"},
"\u2648": {":aries:", ":Aries:"},
"\u2649": {":taurus:", ":Taurus:"},
"\u264a": {":gemini:", ":Gemini:"},
"\u264b": {":cancer:", ":Cancer:"},
"\u264c": {":leo:", ":Leo:"},
"\u264d": {":virgo:", ":Virgo:"},
"\u264e": {":libra:", ":Libra:"},
"\u264f": {":Scorpio:", ":scorpius:"},
"\u2650": {":Sagittarius:", ":sagittarius:"},
"\u2651": {":Capricorn:", ":capricorn:"},
"\u2652": {":Aquarius:", ":aquarius:"},
"\u2653": {":Pisces:", ":pisces:"},
"\u2650": {":sagittarius:", ":Sagittarius:"},
"\u2651": {":capricorn:", ":Capricorn:"},
"\u2652": {":aquarius:", ":Aquarius:"},
"\u2653": {":pisces:", ":Pisces:"},
"\u265f\ufe0f": {":chess_pawn:"},
"\u2660": {":spade_suit:"},
"\u2660\ufe0f": {":spades:"},
@@ -7763,7 +7797,7 @@ func emojiRevCode() map[string][]string {
"\u26c5": {":partly_sunny:", ":sun_behind_cloud:"},
"\u26c8": {":thunder_cloud_rain:", ":cloud_with_lightning_and_rain:"},
"\u26c8\ufe0f": {":thunder_cloud_and_rain:"},
"\u26ce": {":Ophiuchus:", ":ophiuchus:"},
"\u26ce": {":ophiuchus:", ":Ophiuchus:"},
"\u26cf\ufe0f": {":pick:"},
"\u26d1": {":helmet_with_cross:", ":rescue_worker’s_helmet:"},
"\u26d1\ufe0f": {":rescue_worker_helmet:", ":helmet_with_white_cross:"},
+4
View File
@@ -8,6 +8,10 @@ but only releases after v1.0.3 properly adhere to it.
## [Unreleased]
## [1.4.1] - 2026-08-02
### Fixed
- Corrected `D50ToD65` to use the CSS Color 4 matrix inverse of `D65ToD50` (#85).
## [1.4.0] - 2026-03-28
### Added
- Constructors, decomposers, and blend functions for the CSS Color Level 4 wide-gamut RGB color spaces `DisplayP3`, `A98Rgb`, `ProPhotoRgb`, and `Rec2020` (#81)
+3 -3
View File
@@ -10,9 +10,9 @@ import "math"
// Bradford chromatic adaptation between D50 and D65 illuminants.
func D50ToD65(x, y, z float64) (xo, yo, zo float64) {
xo = 0.9555766*x - 0.0230393*y + 0.0631636*z
yo = -0.0282895*x + 1.0099416*y + 0.0210077*z
zo = 0.0122982*x - 0.0204830*y + 1.3299098*z
xo = 0.9554734527042182*x - 0.023098536874261423*y + 0.06325964552894382*z
yo = -0.028369706963208136*x + 1.0099954580058226*y + 0.021041398966943008*z
zo = 0.012314001688319899*x - 0.020507696433477912*y + 1.3303659366080753*z
return
}
+3 -3
View File
@@ -96,10 +96,10 @@ github.com/karimkhaleel/jsonschema
# github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515
## explicit
github.com/kr/logfmt
# github.com/kyokomi/emoji/v2 v2.2.13
## explicit; go 1.14
# github.com/kyokomi/emoji/v2 v2.2.14
## explicit; go 1.21
github.com/kyokomi/emoji/v2
# github.com/lucasb-eyer/go-colorful v1.4.0
# github.com/lucasb-eyer/go-colorful v1.4.1
## explicit; go 1.12
github.com/lucasb-eyer/go-colorful
# github.com/mailru/easyjson v0.7.7