mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-01 19:46:44 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbe2379fa5 | ||
|
|
f77c1d37f1 | ||
|
|
27fcf4b729 | ||
|
|
93b8f343bb | ||
|
|
c18a0c6680 | ||
|
|
c8bc1928f2 | ||
|
|
4b22b844e7 | ||
|
|
ec577f1afa | ||
|
|
70427c8ff5 | ||
|
|
2f06724b80 | ||
|
|
f9b790a1f9 | ||
|
|
ddceff6962 | ||
|
|
f416a4ba6a | ||
|
|
2399cac0db | ||
|
|
d6cf948dca | ||
|
|
4b257646ad | ||
|
|
d3a2c87293 | ||
|
|
f162dc5aec | ||
|
|
d0078bf05c | ||
|
|
5481436d8c | ||
|
|
c3450f9406 | ||
|
|
bc9fafff02 | ||
|
|
5dec89abfe | ||
|
|
4c39b0b903 | ||
|
|
ed22322ec8 | ||
|
|
6c567d1eb6 | ||
|
|
544f3b834b | ||
|
|
da7ca77c9d | ||
|
|
bf5829af3f | ||
|
|
f141fcc570 | ||
|
|
06b421ad0c | ||
|
|
9b1078a2ca | ||
|
|
d19af37ee7 | ||
|
|
34d41b5d51 | ||
|
|
616d75a1fa | ||
|
|
3d80e466ce | ||
|
|
0ce248d1bf | ||
|
|
e10a2f6a27 | ||
|
|
ca6c0500e6 | ||
|
|
e17ed2484c | ||
|
|
180039e78c | ||
|
|
d2d5bdc2bc | ||
|
|
e1b8ef048a | ||
|
|
7cbd93f945 | ||
|
|
d1014aecf3 | ||
|
|
e1391298aa | ||
|
|
813de837ef | ||
|
|
71ff5fd827 | ||
|
|
a9bb960e5c | ||
|
|
900c3e3c45 | ||
|
|
d2a1a4f2a2 | ||
|
|
bd76666d97 | ||
|
|
1b901c7187 | ||
|
|
7e6d5ff7c1 | ||
|
|
b30c734513 | ||
|
|
e54cb4bf42 | ||
|
|
f4968f6839 | ||
|
|
f8b7bab1ab | ||
|
|
8996bd68b9 | ||
|
|
34da956f5d | ||
|
|
4ec91a0bf5 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Vendored
+38
-6
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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() },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user