diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e7538b0d..c6d2fdf6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,16 @@ jobs: - 2.38.2 # first version that supports the rebase.updateRefs config - 2.44.0 - latest # We rely on github to have the latest version installed on their VMs + race: + - false + # Additionally run the whole suite once under the race detector. Data + # races live in lazygit's own Go code rather than in git, so a single + # git version is enough; use the latest to skip the git-build steps. + include: + - git-version: latest + race: true runs-on: ubuntu-latest - name: "Integration Tests - git ${{matrix.git-version}}" + name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}" env: GOFLAGS: -mod=vendor steps: @@ -92,12 +100,23 @@ jobs: run: git --version - name: Test code env: - # See https://go.dev/blog/integration-test-coverage - LAZYGIT_GOCOVERDIR: /tmp/code_coverage + # See https://go.dev/blog/integration-test-coverage. The race variant + # skips coverage: it's redundant with the non-race latest job and + # would only slow the -race build down further. Leaving the dir unset + # makes run_integration_tests.sh take its non-coverage path. + LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }} + # Only set for the race variant. The race detector needs cgo; it's on + # by default on the Linux runner, but we set it explicitly to be safe. + LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }} + CGO_ENABLED: ${{ matrix.race && '1' || '' }} + # Append each test's duration to this file; run_integration_tests.sh + # prints the slowest at the end, to spot slow/anomalous tests. + LAZYGIT_TEST_TIMING: /tmp/test_timings.txt run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh - name: Upload code coverage artifacts + if: ${{ !matrix.race }} uses: actions/upload-artifact@v7 with: name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }} diff --git a/AGENTS.md b/AGENTS.md index 88e818249..2cafd9d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,24 @@ Windows box has only `just`). (most useful with `--sandbox` or `--slow`). - `just lint` — run golangci-lint. +## Prefer gopls MCP tools for Go symbol questions + +When the gopls MCP tools are available in the session, prefer them over grep +for type-aware questions about Go code: who calls a function or method +(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or +inspecting a package's API (`go_package_api`). Method names in this codebase +collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep +needs manual filtering that gopls doesn't. This includes code under +`vendor/`, which gopls resolves as part of the module build. + +Grep remains the right tool for strings, comments, config keys, non-Go +files, and anything textual. Don't adopt the full workflow from +`gopls mcp -instructions` (vulncheck on session start, `go_file_context` +after every file read); that overhead isn't worth it here. + +If the tools aren't available in a session, fall back to grep silently — +don't try to install, register, or start the server. + ## When to commit Do not leave completed work uncommitted. Once a logical unit of work is done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49a9a625c..04095ba77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,5 +30,6 @@ There are other forms of contributions to a project besides source code that are - File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.) - Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios. - Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that. +- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release. Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate. diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index f74005c19..8bdcf164d 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -66,17 +66,17 @@ These can be used in lazygit by using the `externalDiffCommand` config; in the c ```yaml git: pagers: - - externalDiffCommand: difft --color=always + - externalDiffCommand: difft --color=always --context={{diffContext}} ``` -The `colorArg` option is not used in this case. +The `colorArg` option is not used in this case. You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. You can add whatever extra arguments you prefer for your difftool; for instance ```yaml git: pagers: - - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off + - externalDiffCommand: difft --color=always --context={{diffContext}} --display=inline --syntax-highlight=off ``` This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: @@ -84,7 +84,7 @@ This can also be used for normal git diffs with custom parameters, such as `--co ```sh #!/bin/sh -git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +git diff --color-words --no-index --color=always --no-ext-diff --unified=$LAZYGIT_DIFF_CONTEXT "$2" "$5" ``` And then use it in your git config like so: @@ -92,7 +92,7 @@ And then use it in your git config like so: ```yaml git: pagers: - - externalDiffCommand: ~/bin/color-words.sh + - externalDiffCommand: LAZYGIT_DIFF_CONTEXT={{diffContext}} ~/bin/color-words.sh ``` Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using diff --git a/go.mod b/go.mod index 12af7493b..515bb8f1b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 + github.com/gdamore/tcell/v3 v3.4.1 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -38,8 +38,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -65,11 +65,10 @@ require ( github.com/onsi/gomega v1.34.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index 2f734de11..1b8cb7d66 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw= +github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w= +github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -139,19 +139,19 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/justfile b/justfile index 64d9d1ee2..0851179b9 100644 --- a/justfile +++ b/justfile @@ -40,7 +40,7 @@ lint: ./scripts/gofumpt-check.sh ./scripts/golangci-lint-shim.sh run -e2e-test-command := "go test pkg/integration/clients/*.go" +e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go" # Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI. e2e *args: diff --git a/pkg/commands/git.go b/pkg/commands/git.go index d9add2790..ba6e5a033 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -72,6 +72,10 @@ func NewGitCommand( return nil, utils.WrapError(err) } + // 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()) + return NewGitCommandAux( cmn, version, @@ -90,7 +94,7 @@ func NewGitCommandAux( repoPaths *git_commands.RepoPaths, pagerConfig *config.PagerConfig, ) *GitCommand { - cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd) + cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath()) // 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, diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 20d31c11c..9c3cd50d4 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -11,6 +11,15 @@ import ( type gitCmdObjBuilder struct { innerBuilder *oscommands.CmdObjBuilder + + // The directory of the repo (or worktree) this builder was created for; + // every command we produce runs there, regardless of the process's current + // working directory. The two are the same until the user switches to + // another repo: lazygit chdirs on a switch, but work still in flight for + // the previous repo (e.g. a background refresh spawning commands through + // 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 } var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} @@ -21,7 +30,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) *gitCmdObjBuilder { +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir 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{ @@ -33,15 +42,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild return &gitCmdObjBuilder{ innerBuilder: updatedBuilder, + repoDir: repoDir, } } func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar) + return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) Quote(str string) string { diff --git a/pkg/commands/git_cmd_obj_builder_test.go b/pkg/commands/git_cmd_obj_builder_test.go index ca7e54cfe..28e21501c 100644 --- a/pkg/commands/git_cmd_obj_builder_test.go +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -17,8 +17,25 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) { builder := NewGitCmdObjBuilder( utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/repo", ) assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") assert.Contains(t, builder.NewShell("git status", "").GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") } + +// Every command the builder produces runs in the directory of the repo the +// builder was created for, not in the process's current directory: lazygit +// chdirs when switching repos, and commands built for the previous repo after +// that (e.g. by a background refresh still in flight) must keep addressing the +// repo they were built for. +func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) { + builder := NewGitCmdObjBuilder( + utils.NewDummyLog(), + oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/repo", + ) + + 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) +} diff --git a/pkg/commands/git_commands/blame.go b/pkg/commands/git_commands/blame.go index aba1c63fe..e1a9469eb 100644 --- a/pkg/commands/git_commands/blame.go +++ b/pkg/commands/git_commands/blame.go @@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL Arg("--"). Arg(filename) - return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput() + return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput() } diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 6abf272b3..953717ec0 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -243,7 +243,7 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj { func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj { contextSize := self.UserConfig().Git.DiffContextSize - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 3074e653a..f4ecb5f53 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -19,7 +19,8 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom pager if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + contextSize := self.UserConfig().Git.DiffContextSize + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView @@ -32,7 +33,7 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). ArgIf(ignoreWhitespace, "--ignore-all-space"). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 0e5eb299d..9bd960ed5 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -81,7 +81,8 @@ func (self *StashCommands) Hash(index int) (string, error) { } func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + contextSize := self.UserConfig().Git.DiffContextSize + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason @@ -92,7 +93,7 @@ func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + Arg(fmt.Sprintf("--unified=%d", contextSize)). ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg(fmt.Sprintf("refs/stash@{%d}", index)). diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 3b88e4fbb..d8c1208bc 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -28,10 +28,15 @@ func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands { } func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) { - gitModulesPath := ".gitmodules" + // Resolve the path against the repo this commands object was created for + // rather than the process working directory, so that a read from a + // still-running refresh keeps addressing that repo after the user + // switched to another one. + dir := self.repoPaths.WorktreePath() if parentModule != nil { - gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath) + dir = filepath.Join(dir, parentModule.FullPath()) } + gitModulesPath := filepath.Join(dir, ".gitmodules") file, err := os.Open(gitModulesPath) if err != nil { if os.IsNotExist(err) { @@ -180,7 +185,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state - if _, err := os.Stat(submodule.Path); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(self.repoPaths.WorktreePath(), submodule.FullPath())); os.IsNotExist(err) { self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath()) return nil } @@ -213,51 +218,51 @@ func (self *SubmoduleCommands) UpdateAll() error { return self.cmd.New(cmdArgs).Run() } +// runInParentModule runs the given command in the submodule's parent module's +// directory when the submodule is nested: its path arguments (and the +// .gitmodules file the config commands touch) are relative to the parent +// module. The directory is set on the command itself rather than by +// 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). +func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { + if submodule.ParentModule != nil { + cmdObj.SetWd(submodule.ParentModule.FullPath()) + } + return cmdObj.Run() +} + func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error { // based on https://gist.github.com/myusuf3/7f645819ded92bda6677 - if submodule.ParentModule != nil { - wd, err := os.Getwd() - if err != nil { - return err - } - - err = os.Chdir(submodule.ParentModule.FullPath()) - if err != nil { - return err - } - - defer func() { _ = os.Chdir(wd) }() - } - - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("submodule"). Arg("deinit", "--force", "--", submodule.Path).ToArgv(), - ).Run(); err != nil { + )); err != nil { if !strings.Contains(err.Error(), "did not match any file(s) known to git") { return err } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("config"). Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path). ToArgv(), - ).Run(); err != nil { + )); err != nil { return err } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("config"). Arg("--remove-section", "submodule."+submodule.Path). ToArgv(), - ).Run(); err != nil { + )); err != nil { return err } } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(), - ).Run(); err != nil { + )); err != nil { // if the directory isn't there then that's fine self.Log.Error(err) } @@ -282,20 +287,6 @@ func (self *SubmoduleCommands) Add(name string, path string, url string) error { } func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error { - if submodule.ParentModule != nil { - wd, err := os.Getwd() - if err != nil { - return err - } - - err = os.Chdir(submodule.ParentModule.FullPath()) - if err != nil { - return err - } - - defer func() { _ = os.Chdir(wd) }() - } - setUrlCmdStr := NewGitCmd("config"). Arg( "--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl, @@ -303,14 +294,14 @@ func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newU ToArgv() // the set-url command is only for later git versions so we're doing it manually here - if err := self.cmd.New(setUrlCmdStr).Run(); err != nil { + if err := self.runInParentModule(submodule, self.cmd.New(setUrlCmdStr)); err != nil { return err } syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path). ToArgv() - if err := self.cmd.New(syncCmdStr).Run(); err != nil { + if err := self.runInParentModule(submodule, self.cmd.New(syncCmdStr)); err != nil { return err } diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go index 4d15027e1..c6fe2e807 100644 --- a/pkg/commands/git_commands/tag.go +++ b/pkg/commands/git_commands/tag.go @@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).Run() == nil + return self.cmd.New(cmdArgs).DontLog().Run() == nil } func (self *TagCommands) LocalDelete(tagName string) error { @@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).RunWithOutput() + return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { @@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { Arg("refs/tags/" + tagName). ToArgv() - output, err := self.cmd.New(cmdArgs).RunWithOutput() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return strings.TrimSpace(output) == "tag", err } diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8625158ab..d296858f6 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -401,7 +401,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain contextSize := self.UserConfig().Git.DiffContextSize prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain @@ -450,7 +450,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve colorArg = "never" } - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain diff --git a/pkg/commands/git_config/cached_git_config.go b/pkg/commands/git_config/cached_git_config.go index 256cd325b..17152ef9e 100644 --- a/pkg/commands/git_config/cached_git_config.go +++ b/pkg/commands/git_config/cached_git_config.go @@ -16,11 +16,19 @@ type IGitConfig interface { // this is for when you want to pass 'mykey' and check if the result is truthy GetBool(string) bool + // SetDir pins the config commands to the given repo directory, so that + // they keep reading that repo's local config even if the process working + // directory changes later (i.e. the user switches repos while this + // instance is still in use by in-flight work). Called once, before the + // first read. + SetDir(string) + DropCache() } type CachedGitConfig struct { cache map[string]string + dir string runGitConfigCmd func(*exec.Cmd) (string, error) log *logrus.Entry mutex sync.Mutex @@ -39,6 +47,13 @@ func NewCachedGitConfig(runGitConfigCmd func(*exec.Cmd) (string, error), log *lo } } +func (self *CachedGitConfig) SetDir(dir string) { + self.mutex.Lock() + defer self.mutex.Unlock() + + self.dir = dir +} + func (self *CachedGitConfig) Get(key string) string { self.mutex.Lock() defer self.mutex.Unlock() @@ -69,6 +84,7 @@ func (self *CachedGitConfig) GetGeneral(args string) string { func (self *CachedGitConfig) getGeneralAux(args string) string { cmd := getGitConfigGeneralCmd(args) + cmd.Dir = self.dir value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for args: %s. Error: %v", args, err.Error()) @@ -79,6 +95,7 @@ func (self *CachedGitConfig) getGeneralAux(args string) string { func (self *CachedGitConfig) getAux(key string) string { cmd := getGitConfigCmd(key) + cmd.Dir = self.dir value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for key: %s. Error: %v", key, err.Error()) diff --git a/pkg/commands/git_config/cached_git_config_test.go b/pkg/commands/git_config/cached_git_config_test.go index fd884df65..7b92eed1e 100644 --- a/pkg/commands/git_config/cached_git_config_test.go +++ b/pkg/commands/git_config/cached_git_config_test.go @@ -116,3 +116,20 @@ func TestGet(t *testing.T) { assert.Equal(t, "blah", result) assert.Equal(t, 1, count) } + +// The config commands run in the directory set by SetDir rather than in the +// process's current directory: lazygit chdirs when switching repos, and config +// reads issued for the previous repo after that must keep addressing the repo +// they were created for. +func TestSetDirPinsCommandsToDirectory(t *testing.T) { + real := NewCachedGitConfig( + func(cmd *exec.Cmd) (string, error) { + assert.Equal(t, "/path/to/repo", cmd.Dir) + return "blah", nil + }, + utils.NewDummyLog(), + ) + real.SetDir("/path/to/repo") + real.Get("commit.gpgsign") + real.GetGeneral("--local --get-regexp foo") +} diff --git a/pkg/commands/git_config/fake_git_config.go b/pkg/commands/git_config/fake_git_config.go index e82efcd1b..442c18644 100644 --- a/pkg/commands/git_config/fake_git_config.go +++ b/pkg/commands/git_config/fake_git_config.go @@ -28,5 +28,8 @@ func (self *FakeGitConfig) GetBool(key string) bool { return isTruthy(self.Get(key)) } +func (self *FakeGitConfig) SetDir(dir string) { +} + func (self *FakeGitConfig) DropCache() { } diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 72ade5110..eaa762ed6 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -36,7 +36,16 @@ func (p *winPty) Resize(cols, rows uint16) error { // there is nothing left to resize. return nil } - return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)}) + return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows)) +} + +// clampPtySize clamps a requested pty size to the minimum that ConPTY +// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero +// dimensions with E_INVALIDARG, but callers legitimately request them — the +// pty is sized after the main view, which is zero-sized while hidden, e.g. +// in full-screen mode with a side panel focused. +func clampPtySize(cols, rows uint16) windows.Coord { + return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))} } // closeHpc closes the pseudoconsole exactly once. Safe to call from multiple @@ -140,7 +149,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // CreatePseudoConsole dupes the handles it needs internally; we release // our references to the child-side ends immediately after. var hpc windows.Handle - size := windows.Coord{X: int16(cols), Y: int16(rows)} + size := clampPtySize(cols, rows) if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { _ = windows.CloseHandle(inRead) _ = windows.CloseHandle(outWrite) diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go new file mode 100644 index 000000000..0b4173561 --- /dev/null +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -0,0 +1,25 @@ +package oscommands + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The requested size can legitimately be zero: the pty inherits the main +// view's dimensions, and that view is zero-sized while hidden, e.g. in +// full-screen mode with a side panel focused. +func TestStartPtyWithZeroSize(t *testing.T) { + // The command deliberately produces no output: go test runs with + // redirected std handles, which CreateProcess duplicates into the child + // in place of handles to the attached pseudoconsole, so command output + // would bypass the pty and pollute the test log. + sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) + assert.NoError(t, err) + + if err == nil { + _ = sp.Wait() + _ = sp.Pty.Close() + } +} diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index d243a01b2..01f92f584 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -58,12 +58,17 @@ func (self *PagerConfig) GetColorArg() string { return colorArg } -func (self *PagerConfig) GetExternalDiffCommand() string { +func (self *PagerConfig) GetExternalDiffCommand(diffContext uint64) string { currentPagerConfig := self.currentPagerConfig() if currentPagerConfig == nil { return "" } - return currentPagerConfig.ExternalDiffCommand + + templateValues := map[string]string{ + "diffContext": strconv.Itoa(int(diffContext)), + } + + return utils.ResolvePlaceholderString(currentPagerConfig.ExternalDiffCommand, templateValues) } func (self *PagerConfig) GetUseExternalDiffGitConfig() bool { diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 20beb2af7..57818960c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1470,6 +1470,11 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { // flush updates the gui, re-drawing frames and buffers. func (g *Gui) flush() error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + // pretty sure we don't need this, but keeping it here in case we get weird visual artifacts // g.clear(g.FgColor, g.BgColor) @@ -1502,6 +1507,11 @@ func (g *Gui) flush() error { // actually-changed cells are emitted to the terminal. // Will also redraw any views that overlap tainted views func (g *Gui) flushContentOnly(views []*View) error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + for _, v := range viewsToRedrawContentOnly(views) { if err := g.draw(v); err != nil { return err @@ -1555,10 +1565,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error { // draw manages the cursor and calls the draw function of a view. func (g *Gui) draw(v *View) error { - if g.suspended { - return nil - } - if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { return nil } @@ -1930,6 +1936,14 @@ func (g *Gui) onFocus(ev *GocuiEvent) error { return nil } +// While g.suspended is true, nothing must be drawn to the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// which then blocks Resume (and with it all further input) forever. For the +// flag to guarantee that, it must only ever be false while the screen is +// engaged: Suspend sets it before disengaging, and Resume clears it only +// after re-engaging. + func (g *Gui) Suspend() error { g.suspendedMutex.Lock() defer g.suspendedMutex.Unlock() @@ -1940,7 +1954,12 @@ func (g *Gui) Suspend() error { g.suspended = true - return g.screen.Suspend() + if err := g.screen.Suspend(); err != nil { + g.suspended = false + return err + } + + return nil } func (g *Gui) Resume() error { @@ -1951,9 +1970,25 @@ func (g *Gui) Resume() error { return errors.New("Cannot resume because we are not suspended") } + if err := g.screen.Resume(); err != nil { + return err + } + g.suspended = false - return g.screen.Resume() + // Schedule a redraw of the whole screen. Nothing else guarantees one: + // flushes are skipped while suspended, and after re-engaging the screen + // the terminal shows nothing until we draw again. + go func() { g.gEvents <- GocuiEvent{Type: eventResize} }() + + return nil +} + +func (g *Gui) isSuspended() bool { + g.suspendedMutex.Lock() + defer g.suspendedMutex.Unlock() + + return g.suspended } // matchView returns if the keybinding matches the current view (and the view's context) diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go new file mode 100644 index 000000000..ded220bea --- /dev/null +++ b/pkg/gocui/suspend_test.go @@ -0,0 +1,69 @@ +package gocui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// A flush while suspended must return without touching the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// blocking the resume triggered by fg (#5309). The flush runs in a goroutine +// so that a regression fails the test instead of hanging the suite. +func TestFlushIsNoOpWhileSuspended(t *testing.T) { + tests := []struct { + name string + flush func(g *Gui) error + }{ + {"flush", func(g *Gui) error { return g.flush() }}, + {"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Deliberately not newTestGui: its cleanup closes the screen, + // which would deadlock on the screen lock if a regression makes + // the flush below spin. + g, err := NewGui(NewGuiOpts{ + OutputMode: OutputNormal, + Headless: true, + Width: 80, + Height: 24, + }) + assert.NoError(t, err) + + assert.NoError(t, g.Suspend()) + + flushReturned := make(chan error, 1) + go func() { flushReturned <- tc.flush(g) }() + + select { + case err := <-flushReturned: + assert.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("flush touched the suspended screen and got stuck") + } + + assert.NoError(t, g.Resume()) + g.Close() + }) + } +} + +func TestResumeSchedulesRedraw(t *testing.T) { + g := newTestGui(t) + + assert.NoError(t, g.Suspend()) + assert.NoError(t, g.Resume()) + + ev := GocuiEvent{Type: eventNone} + select { + case ev = <-g.gEvents: + case <-time.After(100 * time.Millisecond): + } + + assert.Equal(t, eventResize, ev.Type, + "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") +} diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 1e2db853f..180b2d445 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -6,7 +6,9 @@ import ( "sync/atomic" "time" + "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -106,23 +108,35 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { self.gui.waitForIntro.Wait() fetch := func(firstTimeOrRetriggered bool) error { - // Do this on the UI thread so that we don't have to deal with synchronization around the - // access of the repo state. - self.gui.onUIThread(func() error { - // There's a race here, where we might be recording the time stamp for a different repo - // than where the fetch actually ran. It's not very likely though, and not harmful if it - // does happen; guarding against it would be more effort than it's worth. + // Capture what the fetch needs from the gui's per-repo state in a + // single UI-thread hop: gui.git, gui.helpers and gui.State are all + // replaced on a repo switch (which runs on the UI thread), so reading + // them from this background goroutine would race the reassignment. + // Capturing them together also ties the fetch, the post-fetch + // refresh's generation baseline, and the recorded fetch time to the + // same repo. + var git *commands.GitCommand + var appStatusHelper *helpers.AppStatusHelper + var branchesHelper *helpers.BranchesHelper + var fetchGeneration int + if err := self.gui.g.OnUIThreadAndWaitBackground(func() error { + 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 + } if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { - return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { - return self.backgroundFetch() + return appStatusHelper.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { + return self.backgroundFetch(git, branchesHelper, fetchGeneration) }, nil) } - return self.backgroundFetch() + return self.backgroundFetch(git, branchesHelper, fetchGeneration) } // We want an immediate fetch at startup, and since goEvery starts by @@ -165,7 +179,20 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { } func (self *BackgroundRoutineMgr) checkForExternalChanges() { - current, err := self.gui.git.Status.RefsSnapshot() + // Capture the per-repo objects in a UI-thread hop, like the background + // fetch does: gui.git and gui.helpers are replaced on a repo switch, so + // 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 { + git = self.gui.git + refreshHelper = self.gui.helpers.Refresh + return nil + }); err != nil { + return + } + + current, err := git.Status.RefsSnapshot() if err != nil { // Transient error (e.g. git process couldn't start). Don't update the // stored snapshot; we'll retry next tick. @@ -173,7 +200,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { return } - if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) { + if !refreshHelper.RefsSnapshotChangedSince(current) { return } @@ -231,10 +258,14 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigge }) } -func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { - err = self.gui.git.Sync.FetchBackground() +// The parameters are captured by the caller before the fetch starts, not read +// here after it: the fetch is a network call during which the user may switch +// repos, and the post-fetch refresh needs to be able to tell (see +// PostFetchRefresh). +func (self *BackgroundRoutineMgr) backgroundFetch(git *commands.GitCommand, branchesHelper *helpers.BranchesHelper, fetchGeneration int) error { + err := git.Sync.FetchBackground() - return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true) + return branchesHelper.PostFetchRefresh(err, true, fetchGeneration) } func (self *BackgroundRoutineMgr) triggerImmediateFetch() { diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index d129b3f90..6c8f49e1f 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -642,11 +642,16 @@ func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { - for _, selectedNode := range selectedNodes { - selectedNodePath := selectedNode.GetPath() - nodePath := node.GetPath() + nodePath := node.GetInternalPath() - if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { + for _, selectedNode := range selectedNodes { + if selectedNode.IsFile() { + continue + } + + selectedNodePath := selectedNode.GetInternalPath() + + if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index bf73d4c8b..567b0b6e5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1527,6 +1527,7 @@ func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error } func (self *FilesController) fetch() error { + fetchGeneration := self.c.State().GetRepoGeneration() return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { self.c.LogAction("Fetch") err := self.c.Git().Sync.Fetch(task) @@ -1535,7 +1536,7 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false) + return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false, fetchGeneration) }) } @@ -1551,6 +1552,8 @@ func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.Fil }) } +// NOTE: there's a duplicate of this function in commits_files_controller.go; if you make +// changes here, make them there, too. (We should unify them using generics.) func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { nodePath := node.GetInternalPath() diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 83735d87d..e87edb460 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -392,7 +392,11 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote return nil } -func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) error { +// fetchGeneration must be the repo generation from when the fetch started, +// captured by the caller before running the fetch: the background fetch +// doesn't block repo switching and is a network call, so the window in which +// the user can switch repos spans the whole fetch, not just this refresh. +func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fetchGeneration int) error { scope := []types.RefreshableView{ types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, } @@ -410,6 +414,12 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if fetchErr != nil { return nil } + // Then callbacks are not generation-guarded, so check explicitly: + // if the repo was switched since the fetch started, don't forward + // this repo's branches on the strength of another repo's fetch. + if self.c.State().GetRepoGeneration() != fetchGeneration { + return nil + } err := self.AutoForwardBranches(background) if background && err != nil { // The background poller discards this return value, so surface diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index a958e58a8..e8fa43f2d 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -199,12 +199,12 @@ func (self *FixupHelper) getDiff() (string, bool, error) { // Try staged changes first hasStagedChanges := true - diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput() + diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).DontLog().RunWithOutput() if err == nil && diff == "" { hasStagedChanges = false // If there are no staged changes, try unstaged changes - diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput() + diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).DontLog().RunWithOutput() } return diff, hasStagedChanges, err diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index f2ca7ec17..814a11406 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -139,16 +139,17 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) { self.c.State().ClearItemOperation(opts.Item) // Re-render the context to remove the inline status now that the operation - // finished. Any refresh it triggered must be synchronous, not async: by the - // time we get here a synchronous refresh has already updated the model and - // queued its own re-render, and since UI-thread callbacks run in order, the - // render we queue here runs after it and draws the up-to-date model without - // the inline status. An async refresh might not have updated the model yet, - // so this render could briefly show the stale, pre-operation model: when - // pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind - // counts for a moment before the refresh replaced them with a green - // checkmark. (Operations that don't refresh at all are fine too: there's - // nothing stale to show, so this just drops the status.) + // finished. The operation must trigger its refresh via RefreshFromWorker + // before we get here: that call returns only once the refresh's model + // updates have been enqueued on the UI thread, and since UI-thread + // callbacks run in order, the render we queue here runs after them and + // draws the up-to-date model without the inline status. A refresh whose + // model updates aren't enqueued yet by this point would make this render + // briefly show the stale, pre-operation model: when pushing a branch, for + // example, it would flash the old ↑3↓7 ahead/behind counts for a moment + // before the refresh replaced them with a green checkmark. (Operations + // that don't refresh at all are fine too: there's nothing stale to show, + // so this just drops the status.) self.renderContext(opts.ContextKey) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a7c18aeb8..29784b5ff 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1,12 +1,14 @@ package helpers import ( + "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -79,24 +81,46 @@ func NewRefreshHelper( } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { - self.performRefresh(options, false) + self.performRefresh(options, false, false) +} + +// RefreshBlockingInput is Refresh for handlers whose next keypress may depend +// on the state the refresh produces. See IGuiCommon.RefreshBlockingInput. +func (self *RefreshHelper) RefreshBlockingInput(options types.RefreshOptions) { + self.performRefresh(options, false, true) } // RefreshFromWorker is Refresh for callers already running on a worker // goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI // thread. See IGuiCommon.RefreshFromWorker. func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { - self.performRefresh(options, true) + self.performRefresh(options, true, false) } type refreshEnv struct { - // whether this is a background refresh (which selects the dispatch variant that - // doesn't count towards lazygit being busy) + // Whether everything this refresh dispatches uses the background task + // variants, which don't count towards lazygit being busy — so the refresh + // doesn't block switching repos. Set for refreshes initiated by a + // background routine, and for foreground ones that opted in via + // RefreshOptions.DontBlockRepoSwitch. background bool + // Whether the refresh was initiated by an unattended background routine + // (RefreshOptions.Background) rather than by user activity. The files + // refresh uses this to decide whether git may take optional locks and + // persist its refreshed stat cache. + backgroundRoutine bool + // the repo generation captured when the refresh started generation int + // the git command instance captured when the refresh started. The refresh + // workers run their git commands through this rather than reading the live + // instance: a repo switch mid-refresh replaces the live instance (and the + // process cwd), while this one keeps addressing the repo the refresh was + // started for (its commands are pinned to that repo's directory). + git *commands.GitCommand + // When non-nil, each scope's UI-thread bounce is collected here instead of // being dispatched as it's produced, so they can all be applied in a single // frame once the whole refresh is done (see RefreshOptions.BatchUIUpdates). @@ -141,7 +165,7 @@ func (self *refreshBounceBatch) close() []func() { return self.funcs } -func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { +func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool, blockInput bool) { startTime := time.Now() // A refresh from a worker blocks that worker until it's done; one from the @@ -167,12 +191,42 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } - // Capture the repo generation once, here at the start, so every scope's - // bounce is guarded against the same baseline. - env := refreshEnv{ - background: options.Background, - generation: self.c.State().GetRepoGeneration(), + if options.Then != nil && options.DontBlockRepoSwitch { + // Then is not generation-guarded, so if a switch crossed the refresh it + // would run against the newly switched-to repo. A refresh carrying a + // Then must keep blocking switches. + panic("a refresh with a Then callback must not set DontBlockRepoSwitch") } + + // A RefreshBlockingInput caller wants keyboard input withheld until the + // refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin + // the block synchronously here in the calling handler, so that no keypress + // can slip through before it; the finishing step ends it from a callback + // queued behind the refresh's own updates (see waitAndFinalize). Demos + // take the blocking inline path below and need none of this. + blockInputUntilDone := blockInput && !self.c.InDemo() + if blockInputUntilDone { + self.c.GocuiGui().BeginBlockingEvents() + } + + // Capture the refresh's baseline once, here at the start: the repo + // generation that every scope's bounce is guarded against, and the git + // command instance the scopes run their commands through. The two are + // captured together on the UI thread so that they can't straddle a repo + // switch (which runs on the UI thread): pairing the old repo's instance + // with the new repo's generation would let a refresh compute data from + // the old repo and write it into the new repo's model unguarded. With a + // consistent pair, a switch-crossing refresh keeps running its commands + // against the repo it started in, and the generation guard drops its + // writes. + env := refreshEnv{ + background: options.Background || options.DontBlockRepoSwitch, + backgroundRoutine: options.Background, + } + self.captureOnUIThread(calledFromWorker, env.background, func() { + env.generation = self.c.State().GetRepoGeneration() + env.git = self.c.Git() + }) if options.BatchUIUpdates { env.batch = &refreshBounceBatch{} } @@ -226,7 +280,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // of git's state changing externally while (or right after) we are // refreshing; the risk is one potential extra refresh, but capturing the // snapshot at the end would risk missing one, which is worse. - self.updateRefsSnapshotIfRelevant(scopeSet) + self.updateRefsSnapshotIfRelevant(scopeSet, env) wg := sync.WaitGroup{} refresh := func(name string, f func()) { @@ -268,7 +322,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedReflog capturedReflogState var capturedBranches capturedBranchState self.captureOnUIThread(calledFromWorker, env.background, func() { - capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedCommits = self.captureCommitsState() capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) @@ -461,6 +515,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.onUIThread(env.background, options.Then) } + if blockInputUntilDone { + // Queued after the scopes' model bounces and Then, so by the time + // this runs — and the keys buffered during the refresh replay — + // the refreshed state is in place. + self.c.OnUIThread(func() error { + return self.c.GocuiGui().EndBlockingEvents() + }) + } + self.c.Log.Infof("Refresh took %s", time.Since(startTime)) } @@ -515,12 +578,12 @@ func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { // We check just COMMITS and BRANCHES because the scope-expansion step at the // top of Refresh has already added these whenever REFLOG or BISECT_INFO are // in scope, and whenever a nil scope was passed. -func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) { +func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView], env refreshEnv) { if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) { return } - snapshot, err := self.c.Git().Status.RefsSnapshot() + snapshot, err := env.git.Status.RefsSnapshot() if err != nil { self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err) return @@ -641,7 +704,6 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo // worker computes from an immutable snapshot rather than reading state the UI // thread concurrently mutates. type capturedCommitState struct { - selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool filterPath string @@ -653,17 +715,12 @@ type capturedCommitState struct { // captureCommitsState reads the commits refresh's model/context/mode inputs // into an immutable snapshot. It must run on the UI thread. -func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - +// The selection is captured later, when applying the refresh, so user input +// received while the git work is in flight is not overwritten. +func (self *RefreshHelper) captureCommitsState() capturedCommitState { parentCtx := self.c.Contexts().CommitFiles.GetParentContext() return capturedCommitState{ - selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), filterPath: self.c.Modes().Filtering.GetPath(), @@ -704,15 +761,15 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS } } -func (self *RefreshHelper) determineCheckedOutRef() models.Ref { - if rebasedBranch := self.c.Git().Status.BranchBeingRebased(); rebasedBranch != "" { +func (self *RefreshHelper) determineCheckedOutRef(env refreshEnv) models.Ref { + if rebasedBranch := env.git.Status.BranchBeingRebased(); rebasedBranch != "" { // During a rebase we're on a detached head, so cannot determine the // branch name in the usual way. We need to read it from the // ".git/rebase-merge/head-name" file instead. return &models.Branch{Name: strings.TrimPrefix(rebasedBranch, "refs/heads/")} } - if bisectInfo := self.c.Git().Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { + if bisectInfo := env.git.Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { // Likewise, when we're bisecting we're on a detached head as well. In // this case we read the branch name from the ".git/BISECT_START" file. return &models.Branch{Name: bisectInfo.GetStartHash()} @@ -722,7 +779,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { // checked out. Note that if we're on a detached head (for reasons other // than rebasing or bisecting, i.e. it was explicitly checked out), then // this will return an empty string. - if branchName, err := self.c.Git().Branch.CurrentBranchName(); err == nil && branchName != "" { + if branchName, err := env.git.Branch.CurrentBranchName(); err == nil && branchName != "" { return &models.Branch{Name: branchName} } @@ -731,9 +788,9 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { } func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) error { - checkedOutRef := self.determineCheckedOutRef() - refName, bisectInfo := self.refForLog() - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( + checkedOutRef := self.determineCheckedOutRef(env) + refName, bisectInfo := self.refForLog(env) + commits, err := env.git.Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, @@ -749,9 +806,15 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, if err != nil { return err } - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -770,10 +833,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if captured.selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) + if selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) scrollSelectionIntoView = didMove } } @@ -902,7 +965,7 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit return nil } - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( + commits, err := env.git.Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, @@ -956,7 +1019,7 @@ func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { } func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { - files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) + files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } @@ -975,11 +1038,11 @@ func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPoo } func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, env refreshEnv) error { - updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) + updatedCommits, err := env.git.Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Commits = updatedCommits @@ -991,7 +1054,7 @@ func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, comm } func (self *RefreshHelper) refreshTags(env refreshEnv) error { - tags, err := self.c.Git().Loaders.TagLoader.GetTags() + tags, err := env.git.Loaders.TagLoader.GetTags() if err != nil { return err } @@ -1004,8 +1067,8 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error { return nil } -func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleConfig, error) { - return self.c.Git().Submodule.GetConfigs(nil) +func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*models.SubmoduleConfig, error) { + return env.git.Submodule.GetConfigs(nil) } // self.refreshStatus is called at the end of this because that's when we can @@ -1013,14 +1076,25 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) - branches, err := self.c.Git().Loaders.BranchLoader.Load( + branches, err := env.git.Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, loadBehindCounts, func(f func() error) { self.onWorker(env.background, func(_ gocui.Task) error { - return f() + err := f() + if err != nil && self.c.State().GetRepoGeneration() != env.generation { + // An error returned from a worker is shown in a popup. Don't + // do that if the repo was switched while this worker was in + // flight: its results are dropped anyway, and the error + // concerns a repo the user has already left — e.g. failing to + // compute the behind-counts for a worktree that was deleted + // after switching away from it. + self.c.Log.Warnf("dropping error from a stale refresh worker after a repo switch: %v", err) + return nil + } + return err }) }, func() { @@ -1035,7 +1109,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh var worktrees []*models.Worktree if refreshWorktrees { - worktrees = self.loadWorktrees() + worktrees = self.loadWorktrees(env) } self.onUIThreadUnlessRepoChanged(env, func() { @@ -1100,7 +1174,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh } func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, env refreshEnv) error { - configs, err := self.refreshStateSubmoduleConfigs() + configs, err := self.refreshStateSubmoduleConfigs(env) if err != nil { return err } @@ -1168,11 +1242,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // runs on the UI thread (calledFromWorker is false) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // -// The inline case matters for correctness as much as the hop: a SYNC refresh -// initiated on the UI thread parks that thread in a wg.Wait while its scope -// workers run, so a scope worker that tried to hop to the UI thread there would -// deadlock. Capturing before those workers are spawned — inline, on the UI -// thread — avoids that entirely. +// The inline case matters for correctness as much as the hop: OnUIThreadAndWait +// must not be called from the UI thread itself (it would park the thread +// 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()) { if !calledFromWorker { fn() @@ -1226,7 +1300,11 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re prevConflictFileCount++ } if file.HasInlineMergeConflicts { - hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Path) + // Join with the refresh's repo root rather than relying on the + // 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)) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { @@ -1237,16 +1315,16 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re if len(pathsToStage) > 0 { self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles) - if err := self.c.Git().WorkingTree.StageFiles(pathsToStage, nil); err != nil { + if err := env.git.WorkingTree.StageFiles(pathsToStage, nil); err != nil { return err } } } - files := self.c.Git().Loaders.FileLoader. + files := env.git.Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, - Background: env.background, + Background: env.backgroundRoutine, }) conflictFileCount := 0 @@ -1257,7 +1335,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re } repoState := self.c.State().GetRepoState() - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() if workingTreeState.None() { // No operation is in progress (any more), so forget that we started one. // This also covers an operation that was finished or aborted externally. @@ -1340,7 +1418,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en lastReflogCommit = existing[0] } - commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. + commits, onlyObtainedNewReflogCommits, err := env.git.Loaders.ReflogCommitLoader. GetReflogCommits(captured.hashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return nil, err @@ -1382,7 +1460,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) { - remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() + remotes, err := env.git.Loaders.RemoteLoader.GetRemotes() if err != nil { return nil, err } @@ -1414,8 +1492,8 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env return remotes, nil } -func (self *RefreshHelper) loadWorktrees() []*models.Worktree { - worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() +func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree { + worktrees, err := env.git.Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) return []*models.Worktree{} @@ -1424,7 +1502,7 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { } func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { - worktrees := self.loadWorktrees() + worktrees := self.loadWorktrees(env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees @@ -1437,7 +1515,7 @@ func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { } func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv) { - stashEntries := self.c.Git().Loaders.StashLoader. + stashEntries := env.git.Loaders.StashLoader. GetStashEntries(filterPath) self.onUIThreadUnlessRepoChanged(env, func() { @@ -1449,8 +1527,8 @@ func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus(env refreshEnv) { - workingTreeState := self.c.Git().Status.WorkingTreeState() - repoName := self.c.Git().RepoPaths.RepoName() + workingTreeState := env.git.Status.WorkingTreeState() + repoName := env.git.RepoPaths.RepoName() self.onUIThreadUnlessRepoChanged(env, func() { // Read the checked-out branch and the linked worktree name here on the UI @@ -1473,15 +1551,15 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { // read to decide that. The caller writes the bisect info to the model (in its // bounce) rather than refForLog doing it, so the model write stays on the UI // thread. -func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { - bisectInfo := self.c.Git().Bisect.GetInfo() +func (self *RefreshHelper) refForLog(env refreshEnv) (string, *git_commands.BisectInfo) { + bisectInfo := env.git.Bisect.GetInfo() if !bisectInfo.Started() { return "HEAD", bisectInfo } // need to see if our bisect's current commit is reachable from our 'new' ref. - if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { + if bisectInfo.Bisecting() && !env.git.Bisect.ReachableFromStart(bisectInfo) { return bisectInfo.GetNewHash(), bisectInfo } @@ -1525,18 +1603,18 @@ func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, }) } - githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes), self.c.Git().GitHub.GetAuthToken) + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes, env), env.git.GitHub.GetAuthToken) if len(githubRemotes) == 0 { clearPullRequests() return } - baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) + baseInfo := getGithubBaseRemote(githubRemotes, env.git.GitHub.ConfiguredBaseRemoteName()) if baseInfo == nil { clearPullRequests() - if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { - self.promptForBaseGithubRepo(githubRemotes, branches) + if !self.githubBaseRemotePromptDismissed[env.git.RepoPaths.RepoPath()] { + self.promptForBaseGithubRepo(githubRemotes) } return } @@ -1550,12 +1628,12 @@ type githubRemoteInfo struct { authToken string } -func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote) []githubRemoteInfo { +func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote, env refreshEnv) []githubRemoteInfo { return lo.FilterMap(remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } - serviceInfo, err := self.c.Git().HostingService.GetServiceInfo(remote.Urls[0]) + serviceInfo, err := env.git.HostingService.GetServiceInfo(remote.Urls[0]) if err != nil || serviceInfo.Provider != "github" { return githubRemoteInfo{}, false } @@ -1612,7 +1690,7 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName return nil } -func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo, branches []*models.Branch) { +func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, @@ -1622,11 +1700,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - // This fetch runs on its own worker after the user picked a - // base remote, so it's not part of a performRefresh and has no - // ambient env; build a foreground one now, capturing the - // current generation as the guard baseline. - self.setGithubPullRequests(&info, branches, refreshEnv{generation: self.c.State().GetRepoGeneration()}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.PULL_REQUESTS}}) return nil }) }, @@ -1666,13 +1740,13 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra return branch.UpstreamBranch }) - prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) + prs, err := env.git.GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) if err != nil { self.c.Log.Error("error fetching pull requests from GitHub: " + err.Error()) return } - self.savePullRequestsToCache(prs) + self.savePullRequestsToCache(prs, env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().PullRequests = prs @@ -1684,8 +1758,12 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra }) } -func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest) { - repoPath := self.c.Git().RepoPaths.RepoPath() +func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest, env refreshEnv) { + // Key the cache by the repo the refresh was started for, not the live one: + // this runs on a worker, and if the user switched repos while the fetch was + // in flight, the live instance would file the old repo's pull requests + // under the new repo's path. + repoPath := env.git.RepoPaths.RepoPath() cached := lo.Map(prs, func(pr *models.GithubPullRequest, _ int) config.CachedPullRequest { return config.CachedPullRequest{ HeadRefName: pr.HeadRefName, diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 3235586b0..153dd1af8 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -160,12 +160,17 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN if err := self.c.Git().Branch.CreateWithUpstream(localBranchName, fullBranchName); err != nil { return err } - // Do a sync refresh to make sure the new branch is visible, - // so that we see an inline status when checking it out + // Refresh the branches and check out from Then, so that the + // new branch is already in the model when CheckoutRef looks + // it up; that's what makes it show an inline status on the + // branch rather than a global waiting status. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES}, + Then: func() error { + return checkout(localBranchName, true) + }, }) - return checkout(localBranchName, true) + return nil }, }, { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 2da502b79..708f8fc28 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -741,7 +741,10 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().MoveSelection(1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input until the refresh has landed: a quick second press must + // read the moved todo from the refreshed model, not grab whatever the + // advanced selection index points at in the stale one. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -777,7 +780,8 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().MoveSelection(-1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input for the same reason as in moveDown. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 76bd16bb1..cd05e5ff9 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -159,8 +159,7 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl // Refresh the remotes so that we can select the new one. The remotes model // update is bounced onto the UI thread, so the selection (which reads // Model.Remotes) has to run in Then; reading it inline here would see the - // previous model. Loading remotes is not expensive, so a sync refresh is - // affordable. + // previous model. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, Then: func() error { diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acda..505a07fc4 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -229,7 +229,10 @@ func (self *StagingController) applySelectionAndRefresh(reverse bool) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input until the refresh has landed: it rebuilds the staging panel + // and moves the selection to the next stageable change, and a quick second + // keypress must act on that, not on the stale pre-refresh diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } @@ -284,7 +287,9 @@ func (self *StagingController) EditHunkAndRefresh() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input like applySelectionAndRefresh does; the refresh rebuilds the + // staging panel from the post-edit diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index a2b7e9e97..d01fc8dbf 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,13 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.DropStash) - // Refresh once at the end rather than after each drop: an async - // refresh from the UI thread finishes in the background, so firing - // one per 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. - defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // Refresh once at the end rather than after each drop: a refresh + // from the UI thread finishes in the background, so firing one per + // 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}}) 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 { @@ -192,7 +195,11 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) } func (self *StashController) postStashRefresh() { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + // 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}}) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { @@ -214,12 +221,15 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr self.c.LogAction(self.c.Tr.Actions.RenameStash) err := self.c.Git().Stash.Rename(stashEntry.Index, response) if err != nil { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return err } self.context().SetSelection(0) // Select the renamed stash self.context().FocusLine(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // Renaming re-creates the stash at the top, shifting the other + // entries' indices; block input so that a quick next action sees + // the refreshed list rather than the stale indices. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return nil }, AllowEmptyInput: true, diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 533c01fbd..912d46567 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -390,7 +390,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context } gui.c.Log.Info("Receiving focus - refreshing") - gui.helpers.Refresh.Refresh(types.RefreshOptions{}) + gui.helpers.Refresh.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) return reloadErr } @@ -1031,7 +1031,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb return err } - gui.c.Refresh(types.RefreshOptions{}) + gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) return nil } @@ -1103,12 +1103,31 @@ func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error { return err } +var isFirstRefreshAfterStartup = true + func (gui *Gui) loadNewRepo() error { if err := gui.updateRecentRepoList(); err != nil { return err } - gui.c.Refresh(types.RefreshOptions{}) + // On startup we don't want to block input during the initial refresh (it + // should be possible to press, say, `4` to jump to the commits panel right + // after startup without a delay), and we also want panels to show their + // contents as soon as possible; it doesn't matter so much that it's not in + // sync, we go from empty to populated here. However, when switching repos + // it can be confusing that some panels that are slow to update still show + // the old repo's data while others already show the new one's data, so + // update the UI only when everything is ready, and also block input to + // prevent accidentally trying to act on the old, stale data. + options := types.RefreshOptions{DontBlockRepoSwitch: true} + refresh := gui.c.Refresh + if isFirstRefreshAfterStartup { + isFirstRefreshAfterStartup = false + } else { + options.BatchUIUpdates = true + refresh = gui.c.RefreshBlockingInput + } + refresh(options) if err := gui.os.UpdateWindowTitle(); err != nil { return err diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 80b2b9ded..e7b14ba04 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) { self.gui.helpers.Refresh.Refresh(opts) } +func (self *guiCommon) RefreshBlockingInput(opts types.RefreshOptions) { + self.gui.helpers.Refresh.RefreshBlockingInput(opts) +} + func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { self.gui.helpers.Refresh.RefreshFromWorker(opts) } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 7bd31d93d..9e06f483b 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -25,17 +25,27 @@ type GuiDriver struct { var _ integrationTypes.GuiDriver = &GuiDriver{} func (self *GuiDriver) PressKey(keyStr string) { + self.PressKeysRapidly(keyStr) +} + +// PressKeysRapidly presses the given keys in immediate succession, waiting for +// lazygit to become idle only after the last one. Keys pressed this way can +// arrive while the previous key's processing is still in flight, like a user +// typing faster than lazygit handles the input. +func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { self.CheckAllToastsAcknowledged() - key, ok := config.KeyFromLabel(keyStr) - if !ok { - self.Fail("Unrecognized key: " + keyStr) - } + for _, keyStr := range keyStrs { + key, ok := config.KeyFromLabel(keyStr) + if !ok { + self.Fail("Unrecognized key: " + keyStr) + } - self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( - tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), - 0, - )) + self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( + tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), + 0, + )) + } self.waitTillIdle() } @@ -67,6 +77,25 @@ func (self *GuiDriver) FocusIn() { self.waitTillIdle() } +func (self *GuiDriver) FocusInAndClick(x, y int) { + self.CheckAllToastsAcknowledged() + + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + )) + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), + 0, + )) + self.waitTillIdle() + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), + 0, + )) + self.waitTillIdle() +} + func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { self.gui.onUIThread(func() error { self.gui.State.SetMergeOrRebaseStartedInLazygit(true) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index d4f739c6d..e10ea8ec7 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -58,6 +58,7 @@ func (p ptyCmd) GetProcess() *os.Process { return p.process } // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() + diffContext := gui.UserConfig().Git.DiffContextSize // LAZYGIT_COLUMNS is documented in docs/Custom_Pagers.md for pager // scripts that can't query the terminal width directly. We set it on @@ -65,7 +66,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) - externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand() + externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand(diffContext) useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() if pager == "" && externalDiffCommand == "" && !useExtDiffGitConfig { @@ -99,6 +100,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cols, rows := gui.desiredPtySize(view) var p oscommands.Pty + var fallbackPipe io.ReadCloser start := func() (tasks.Cmd, io.Reader) { // The pty (and pager) wrap to this width; apply it here, on the // task's goroutine once the previous task has stopped, so it doesn't @@ -108,7 +110,11 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) - return tasks.ExecCmd{Cmd: cmd}, nil + // Fall back to running the command without a pty: the pager is + // lost, but the command's output still renders. + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + fallbackPipe = pipe + return execCmd, pipe } p = sp.Pty @@ -124,6 +130,10 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error if p != nil { p.Close() } + if fallbackPipe != nil { + fallbackPipe.Close() + fallbackPipe = nil + } delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 27aacf58b..3dce93874 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" + "github.com/sirupsen/logrus" ) func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { @@ -29,19 +30,9 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error start := func() (tasks.Cmd, io.Reader) { view.SetContentWidth(contentWidth) - var err error - r, err = cmd.StdoutPipe() - if err != nil { - gui.c.Log.Error(err) - r = nil - } - cmd.Stderr = cmd.Stdout - - if err := cmd.Start(); err != nil { - gui.c.Log.Error(err) - } - - return tasks.ExecCmd{Cmd: cmd}, r + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + r = pipe + return execCmd, pipe } onClose := func() { @@ -59,6 +50,27 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// startCmdWithPipe starts cmd with its stdout and stderr going to a single +// pipe, and returns the command along with the pipe's read end, in the shape +// that NewCmdTask expects from its start func. It never returns a nil reader, +// because NewCmdTask's scanner panics on one: when the pipe can't be created +// the command isn't started at all, and an empty reader is returned so that +// the task shuts down cleanly with the error in the log. +func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { + r, err := cmd.StdoutPipe() + if err != nil { + log.Error(err) + return tasks.ExecCmd{Cmd: cmd}, io.NopCloser(strings.NewReader("")) + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Error(err) + } + + return tasks.ExecCmd{Cmd: cmd}, r +} + func (gui *Gui) newStringTask(view *gocui.View, str string) error { // using str so that if rendering the exact same thing we don't reset the origin return gui.newStringTaskWithKey(view, str, str) diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go new file mode 100644 index 000000000..48c1bb45f --- /dev/null +++ b/pkg/gui/tasks_adapter_test.go @@ -0,0 +1,24 @@ +package gui + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { + cmd := exec.Command("non-existent-command") + // Assigning stdout up front makes cmd.StdoutPipe fail. This happens in + // practice on the Unix pty fallback path: a failed pty start can leave + // the tty assigned to the command's stdout. + cmd.Stdout = &bytes.Buffer{} + + _, r := startCmdWithPipe(cmd, utils.NewDummyLog()) + + // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must + // not return one even when it can't create the pipe. + assert.NotNil(t, r) +} diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index d6893c92d..2644e989b 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -3,6 +3,7 @@ package gui import ( "log" "os" + "runtime/pprof" "time" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -45,9 +46,14 @@ func (gui *Gui) handleTestMode() { }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { + timeout := 40 * time.Second * testTimeoutMultiplier go utils.Safe(func() { - time.Sleep(time.Second * 40) - log.Fatal("40 seconds is up, lazygit recording took too long to complete") + time.Sleep(timeout) + // Dump all goroutine stacks before dying, so a hung test shows + // where it got stuck rather than just that it timed out. The + // test harness surfaces this process's stderr on failure. + _ = pprof.Lookup("goroutine").WriteTo(os.Stderr, 2) + log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout) }) } } diff --git a/pkg/gui/test_timeout_norace.go b/pkg/gui/test_timeout_norace.go new file mode 100644 index 000000000..7f924ea71 --- /dev/null +++ b/pkg/gui/test_timeout_norace.go @@ -0,0 +1,5 @@ +//go:build !race + +package gui + +const testTimeoutMultiplier = 1 diff --git a/pkg/gui/test_timeout_race.go b/pkg/gui/test_timeout_race.go new file mode 100644 index 000000000..7d633def3 --- /dev/null +++ b/pkg/gui/test_timeout_race.go @@ -0,0 +1,10 @@ +//go:build race + +package gui + +// The race detector makes everything run several times slower, so the +// recording watchdog needs a correspondingly longer timeout; otherwise it +// fires on tests that are merely slow under -race rather than actually stuck. +// The `race` build tag is set automatically when the binary is built with +// -race, so this can't drift out of sync with the actual build. +const testTimeoutMultiplier = 4 diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6d11e29db..87bd9ef50 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -29,6 +29,17 @@ type IGuiCommon interface { LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) + // Like Refresh, but withholds keyboard input until the refreshed state is + // in place: keys pressed while the refresh is in flight are buffered and + // replayed once its model and view updates have run, instead of being + // handled against the stale, pre-refresh state. Use it when the very next + // keypress may depend on what the refresh produces — e.g. staging a hunk, + // where the refresh moves the selection to the next stageable hunk that + // the next press is meant to stage. Keep it to quick, narrow-scoped + // refreshes: one that includes COMMITS (or refreshes everything) can take + // very long in large repos and should usually not block input unless + // there's a very good reason (switching repos is one such example). + RefreshBlockingInput(RefreshOptions) // Like Refresh, but for callers running on a worker goroutine (e.g. inside // a WithWaitingStatus handler) rather than the UI thread. The refresh // captures the model/context state it needs on the UI thread before doing diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 937c3a30e..c733e589e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -94,4 +94,19 @@ type RefreshOptions struct { // fast. Background refreshes leave the suppression in place: not persisting // the stat-cache is the right trade-off for unattended work. Background bool + + // When true, this foreground refresh does not block switching repos while + // it is in flight. A refresh is switch-safe by construction — its git + // commands run against the repo it was started for, and the generation + // guard drops its model/view updates if the repo changed — but a refresh + // triggered by a user operation still blocks switching (its tasks count + // towards Busy()), because the operation's follow-up work isn't covered + // by those guards. A refresh that merely reloads state (on focus, after a + // repo switch, after returning from a subprocess) has no such follow-up, + // so it opts in here and a repo switch during it is allowed rather than + // refused with a toast. + // + // Must not be combined with Then: Then is not generation-guarded, so it + // would run against the newly switched-to repo. + DontBlockRepoSwitch bool } diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 11f6e754e..4c1faa557 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -11,7 +11,9 @@ import ( "io" "os" "os/exec" + "syscall" "testing" + "time" "github.com/creack/pty" "github.com/jesseduffield/lazycore/pkg/utils" @@ -28,6 +30,7 @@ func TestIntegration(t *testing.T) { parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != "" + logTimingsPath := os.Getenv("LAZYGIT_TEST_TIMING") // LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory // is defined, go binaries built with the -cover flag will write coverage files to // to it. @@ -56,7 +59,8 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 1, + MaxAttempts: 1, + LogTimingsPath: logTimingsPath, }) assert.NoError(t, err) @@ -75,6 +79,17 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { stderr := new(bytes.Buffer) cmd.Stderr = stderr + // If lazygit exits but leaves behind a subprocess that inherited its stderr + // pipe, cmd.Wait blocks waiting for that pipe to reach EOF for as long as the + // subprocess stays alive. Unbounded, that hangs the whole test binary until + // its global timeout fires, and the timeout throws away whatever lazygit + // wrote to stderr before exiting (a panic, a -race report) -- the very output + // needed to diagnose the failure. WaitDelay caps the wait: once the process + // has exited, Wait gives the stderr goroutine at most this long to drain, + // then closes the pipe and returns ErrWaitDelay, so the captured stderr + // surfaces as the test error instead of being lost. + cmd.WaitDelay = 5 * time.Second + // these rows and columns are ignored because internally we use tcell's // simulation screen. However we still need the pty for the sake of // running other commands in a pty. @@ -83,12 +98,32 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { return -1, err } + // pty.StartWithSize starts lazygit in its own process group, so we can signal + // the whole group at once. Capture the id now, while the process is alive: + // once Wait has reaped it we can no longer look it up. + pgid, pgidErr := syscall.Getpgid(cmd.Process.Pid) + _, _ = io.Copy(io.Discard, f) - if cmd.Wait() != nil { + waitErr := cmd.Wait() + + // On any failure -- including a WaitDelay expiry caused by a leaked + // subprocess -- kill the whole process group so a straggler can't linger and + // wedge a later test or pile up across a CI run. Best effort: usually the + // group is already gone (ESRCH), and a subprocess that called setsid to + // detach into its own group is out of reach, but WaitDelay still unblocks us. + if waitErr != nil && pgidErr == nil { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + + if waitErr != nil { _ = f.Close() - // return an error with the stderr output - return cmd.Process.Pid, errors.New(stderr.String()) + // Prefer lazygit's own stderr as the error; fall back to the wait error + // itself (e.g. ErrWaitDelay) when it exited without printing anything. + if stderr.Len() > 0 { + return cmd.Process.Pid, errors.New(stderr.String()) + } + return cmd.Process.Pid, waitErr } return cmd.Process.Pid, f.Close() diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 5640c3e70..78cb5439f 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "sync" + "time" lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -24,6 +26,12 @@ type RunTestArgs struct { CodeCoverageDir string InputDelay int MaxAttempts int + // If set, each test's run duration is appended to this file (as + // " "). run_integration_tests.sh prints the slowest at + // the end, so slow or anomalous tests can be spotted across CI runs. We + // write to a file rather than stdout/stderr because `go test` captures + // those and only shows them with -v. Empty disables it. + LogTimingsPath string } // This function lets you run tests either from within `go test` or from a regular binary. @@ -47,6 +55,11 @@ func RunTests(args RunTestArgs) error { return err } + // Start each run with a fresh timings file (see RunTestArgs.LogTimingsPath). + if args.LogTimingsPath != "" { + _ = os.Remove(args.LogTimingsPath) + } + for _, test := range args.Tests { args.TestWrapper(test, func() error { paths := NewPaths( @@ -99,7 +112,11 @@ func runTest( return err } + start := time.Now() pid, err := args.RunCmd(cmd) + if args.LogTimingsPath != "" { + logTestTiming(args.LogTimingsPath, test.Name(), time.Since(start)) + } // Print race detector log regardless of the command's exit status if args.RaceDetector { @@ -112,6 +129,23 @@ func runTest( return err } +// timingsMutex serializes appends to the timings file, since tests run in +// parallel. +var timingsMutex sync.Mutex + +func logTestTiming(path, name string, duration time.Duration) { + timingsMutex.Lock() + defer timingsMutex.Unlock() + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + + fmt.Fprintf(f, "%.2f %s\n", duration.Seconds(), name) +} + func prepareTestDir( test *IntegrationTest, paths Paths, diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 376b0f4d6..19219707a 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "strings" "time" "github.com/jesseduffield/lazygit/pkg/config" @@ -42,6 +43,15 @@ func (self *TestDriver) pressFast(keyStr string) { self.Wait(self.inputDelay / 5) } +// presses the keys in immediate succession, without waiting for lazygit to +// become idle in between, to simulate a user typing faster than lazygit +// processes the input +func (self *TestDriver) pressRapidly(keyStrs []string) { + self.SetCaption(fmt.Sprintf("Pressing %s", strings.Join(keyStrs, ", "))) + self.gui.PressKeysRapidly(keyStrs...) + self.Wait(self.inputDelay) +} + func (self *TestDriver) click(x, y int) { self.SetCaption(fmt.Sprintf("Clicking %d, %d", x, y)) self.gui.Click(x, y) @@ -63,6 +73,12 @@ func (self *TestDriver) FocusIn() { self.Wait(self.inputDelay) } +func (self *TestDriver) focusInAndClick(x, y int) { + self.SetCaption(fmt.Sprintf("Focusing window and clicking %d, %d", x, y)) + self.gui.FocusInAndClick(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index e7fd0b66a..7196779eb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -30,6 +30,10 @@ func (self *fakeGuiDriver) PressKey(key string) { self.pressedKeys = append(self.pressedKeys, key) } +func (self *fakeGuiDriver) PressKeysRapidly(keys ...string) { + self.pressedKeys = append(self.pressedKeys, keys...) +} + func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } @@ -37,6 +41,10 @@ func (self *fakeGuiDriver) Click(x, y int) { func (self *fakeGuiDriver) FocusIn() { } +func (self *fakeGuiDriver) FocusInAndClick(x, y int) { + self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index df4b9d7d8..2cfaba338 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -454,6 +454,19 @@ func (self *ViewDriver) PressFast(key config.Keybinding) *ViewDriver { return self } +// Presses the given keys in immediate succession, without waiting for lazygit +// to become idle in between (Press waits after every key). Use this to +// simulate a user typing faster than lazygit processes the input. +func (self *ViewDriver) PressRapidly(keys ...config.Keybinding) *ViewDriver { + self.IsFocused() + + self.t.pressRapidly(lo.Map(keys, func(key config.Keybinding, _ int) string { + return key[0] + })) + + return self +} + func (self *ViewDriver) Click(x, y int) *ViewDriver { offsetX, offsetY, _, _ := self.getView().Dimensions() @@ -462,6 +475,14 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver { return self } +func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + + self.t.focusInAndClick(offsetX+1+x, offsetY+1+y) + + return self +} + // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go new file mode 100644 index 000000000..cfd4e23c5 --- /dev/null +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -0,0 +1,26 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep a clicked commit selected when focus-in immediately precedes the click", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-02").IsSelected(), + Contains("commit-01"), + ). + FocusInAndClick(1, 1). + SelectedLine(Contains("commit-01")) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go new file mode 100644 index 000000000..16e58e18e --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -0,0 +1,60 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second keypress arrives before the refresh triggered by the first one +// has rebuilt the commits model. The handler reads the selected todo from the +// model at the already-advanced selection index, so with the stale, pre-move +// model it grabs the todo the first move swapped with and moves that one back +// down — turning the two presses into a net no-op instead of moving the +// selected todo down two slots. This is what happens when holding down the +// move-down key to move a todo several slots. +// +// We continue the rebase and assert the resulting commit order rather than +// asserting the todo list, because the two presses also spawn two racing +// refreshes whose updates can land in either order, so what the todo list +// shows in the broken state is not deterministic (it can even disagree with +// the todo file). The rebase replays what's in the file. +var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a todo down two slots with two keypresses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(4) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + NavigateToLine(Contains("commit-01")). + Press(keys.Universal.Edit). + Lines( + Contains("--- Pending rebase todos ---"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("--- Commits ---"), + Contains("commit-01").IsSelected(), + ). + NavigateToLine(Contains("commit-04")). + PressRapidly(keys.Commits.MoveDownCommit, keys.Commits.MoveDownCommit). + Tap(func() { + t.Common().ContinueRebase() + }). + Lines( + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04"), + Contains("commit-01"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go new file mode 100644 index 000000000..bd20e4ff5 --- /dev/null +++ b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go @@ -0,0 +1,53 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectDirecoriesSharingPrefix = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select directories sharing a prefix in the commit files view and add them to a custom patch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("foo/file", "file1 content") + shell.CreateFileAndAdd("foobar/file", "file2 content") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ foo"), + Equals(" A file"), + Equals(" ▼ foobar"), + Equals(" A file"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("foobar")). + PressPrimaryAction(). + Lines( + Equals("▼ /"), + Equals(" ▼ foo").IsSelected(), + Equals(" ● file").IsSelected(), + Equals(" ▼ foobar").IsSelected(), + Equals(" ● file"), + ) + + t.Views().Information().Content(Contains("Building patch")) + + t.Views().Secondary().Content( + Contains("foo/file").Contains("foobar/file"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go new file mode 100644 index 000000000..5b41073e2 --- /dev/null +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -0,0 +1,50 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second space is pressed before the refresh triggered by the first one +// has updated the staging panel. That refresh is what moves the selection to +// the next hunk, so the second press must not be handled until it has landed; +// handling it earlier would try to stage the first hunk a second time. +var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage two hunks with two space presses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = true + }, + SetupRepo: func(shell *Shell) { + // Use 7 context lines between the two change blocks so that git creates + // two separate hunks. + shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") + shell.Commit("one") + + shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + PressEnter() + + t.Views().Staging(). + IsFocused(). + PressRapidly(keys.Universal.Select, keys.Universal.Select) + + t.Views().StagingSecondary(). + IsFocused(). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8213d5159..77e54e265 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{ commit.Highlight, commit.History, commit.HistoryComplex, + commit.KeepClickedCommitSelectedAfterFocusIn, commit.KeepSelectedCommitAfterExternalCommit, commit.NewBranch, commit.PasteCommitMessage, @@ -310,6 +311,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.Move, interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase, interactive_rebase.MoveInRebase, + interactive_rebase.MoveTodoDownWithRapidKeypresses, interactive_rebase.MoveUpdateRefTodo, interactive_rebase.MoveWithCustomCommentChar, interactive_rebase.OutsideRebaseRangeSelect, @@ -380,6 +382,7 @@ var tests = []*components.IntegrationTest{ patch_building.RenamedFileWhole, patch_building.ResetWithEscape, patch_building.SelectAllFiles, + patch_building.SelectDirecoriesSharingPrefix, patch_building.SpecificSelection, patch_building.StartNewPatch, patch_building.ToggleDirectory, @@ -403,6 +406,7 @@ var tests = []*components.IntegrationTest{ staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, staging.StageHunks, + staging.StageHunksWithRapidKeypresses, staging.StageLines, staging.StagePartialBlockOfChangesFirstLines, staging.StagePartialBlockOfChangesLastLines, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 34ce499cc..ea76c45be 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -23,10 +23,17 @@ type IntegrationTest interface { // this is the interface through which our integration tests interact with the lazygit gui type GuiDriver interface { PressKey(string) + // Like PressKey, but presses several keys in immediate succession, waiting + // for lazygit to become idle only after the last one. Use it to simulate a + // user typing faster than lazygit processes the input. + PressKeysRapidly(...string) Click(int, int) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() + // Simulate a terminal dispatching focus-in immediately followed by a click, + // without waiting for the focus refresh to finish in between. + FocusInAndClick(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 579e6d77c..2bf010f19 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -19,7 +19,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # hacky. To capture the coverage data for the test runner we pass the test.gocoverdir positional # arg, but if we do that then the GOCOVERDIR env var (which you typically pass to the test binary) will be overwritten by the test runner. So we're passing LAZYGIT_COCOVERDIR instead # and then internally passing that to the test binary as GOCOVERDIR. - go test -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" + go test -timeout 30m -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" EXITCODE=$? # We're merging the coverage data for the sake of having fewer artefacts to upload. @@ -29,7 +29,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then rm -rf /tmp/code_coverage mv /tmp/code_coverage_merged /tmp/code_coverage else - go test pkg/integration/clients/*.go + go test -timeout 30m pkg/integration/clients/*.go EXITCODE=$? fi @@ -37,4 +37,12 @@ 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. +if [ -n "$LAZYGIT_TEST_TIMING" ] && [ -f "$LAZYGIT_TEST_TIMING" ]; then + echo "Test timings (seconds):" + sort -rn "$LAZYGIT_TEST_TIMING" +fi + exit $EXITCODE diff --git a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md index 26a0c2e57..c3fcb3f4f 100644 --- a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md +++ b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md @@ -100,7 +100,7 @@ These functions weren't reliable and served no useful purpose. `NewConsoleScreen` is removed as is support for Windows console mode. Instead this uses the more modern Windows VT modes. -As a consequence, this means that _Tcell_ on Windows requires at least Winows 10 build 1703 (the Creators Update). +As a consequence, this means that _Tcell_ on Windows requires at least Windows 10 build 1703 (the Creators Update). If you are using a version of Windows 10 older than that, you should really upgrade for _many_ reasons, not just because _Tcell_ doesn't support it anymore. @@ -108,3 +108,11 @@ because _Tcell_ doesn't support it anymore. This structure, and the associated `NewInputProcessor` function, were made public incorrectly. They are not part of our public API going forward, and are now private symbols. + +## SimulationScreen is Removed + +While never part of the public _Tcell_ API, some projects may have used the +`SimulationScreen` for their own tests. That facility was very limited, and +we implemented a much more complete emulation of a terminal in `MockScreen` +and `MockTerm`. (To be clear, those facilities are still intended for _Tcell_'s +own testing, and are still not part of the public API.) diff --git a/vendor/github.com/gdamore/tcell/v3/README-wasm.md b/vendor/github.com/gdamore/tcell/v3/README-wasm.md index 4e29a3dac..1a92a4e5c 100644 --- a/vendor/github.com/gdamore/tcell/v3/README-wasm.md +++ b/vendor/github.com/gdamore/tcell/v3/README-wasm.md @@ -24,6 +24,8 @@ cp -R webfiles/ghostty-web /path/to/dir/to/serve/ The vendored `ghostty-web.js` is intentionally browser-only. Its upstream Node `readFile` fallback import is removed so browser-oriented servers and bundlers such as Vite do not try to resolve a Node file-system shim; the bundled code loads `ghostty-vt.wasm` with `fetch`. +The vendored `ghostty-web.js` is also de-inlined: upstream embeds a base64 copy of `ghostty-vt.wasm` twice inside the JS (as default candidates for `Ghostty.load()`), which more than tripled the shipped bytes. Those inline `data:application/wasm;base64,...` defaults are removed; `tcell.js` passes an explicit URL to `Ghostty.load()`, and the `./ghostty-vt.wasm` / `/ghostty-vt.wasm` relative paths remain as no-argument fallbacks. The wasm is therefore shipped once, as the separate `ghostty-vt.wasm`. + For example: ```sh diff --git a/vendor/github.com/gdamore/tcell/v3/input.go b/vendor/github.com/gdamore/tcell/v3/input.go index 58c8a7e0d..74e234d1e 100644 --- a/vendor/github.com/gdamore/tcell/v3/input.go +++ b/vendor/github.com/gdamore/tcell/v3/input.go @@ -222,6 +222,7 @@ var csiAllKeys = map[csiParamMode]keyMap{ {M: 'L'}: {Key: KeyInsert}, {M: 'P'}: {Key: KeyF1}, // except for aixterm, where this is Delete {M: 'Q'}: {Key: KeyF2}, + {M: 'R'}: {Key: KeyF3}, {M: 'S'}: {Key: KeyF4}, {M: 'Z'}: {Key: KeyBacktab}, {M: 'a'}: {Key: KeyUp, Mod: ModShift}, diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 9fe3cf8fe..d50101903 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -1009,6 +1009,13 @@ func (t *tScreen) hideCursor() { } func (t *tScreen) draw() { + if !t.running { + // While disengaged (e.g. suspended) the terminal belongs to some + // other application, so we must not emit anything; also the cell + // buffer is released, so there is nothing valid to draw from. + return + } + // clobber cursor position, because we're going to change it all t.cx = -1 t.cy = -1 @@ -1040,6 +1047,10 @@ func (t *tScreen) draw() { // actually will *draw* it. t.cells.SetDirty(x+1, y, true) } + } else if width < 1 { + // drawCell reports width 0 for coordinates outside the + // cell buffer; never let the scan stall + width = 1 } x += width - 1 } diff --git a/vendor/golang.org/x/mod/modfile/read.go b/vendor/golang.org/x/mod/modfile/read.go index 504a2f1df..5b528c718 100644 --- a/vendor/golang.org/x/mod/modfile/read.go +++ b/vendor/golang.org/x/mod/modfile/read.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "os" + "slices" "strconv" "strings" "unicode" @@ -105,8 +106,7 @@ func (x *FileSyntax) addLine(hint Expr, tokens ...string) *Line { if hint == nil { // If no hint given, add to the last statement of the given type. Loop: - for i := len(x.Stmt) - 1; i >= 0; i-- { - stmt := x.Stmt[i] + for _, stmt := range slices.Backward(x.Stmt) { switch stmt := stmt.(type) { case *Line: if stmt.Token != nil && stmt.Token[0] == tokens[0] { @@ -718,9 +718,7 @@ func (in *input) assignComments() { } // Assign suffix comments to syntax immediately before. - for i := len(in.post) - 1; i >= 0; i-- { - x := in.post[i] - + for _, x := range slices.Backward(in.post) { start, end := x.Span() if debug { fmt.Fprintf(os.Stderr, "post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte) diff --git a/vendor/golang.org/x/mod/modfile/rule.go b/vendor/golang.org/x/mod/modfile/rule.go index c5b8305de..9ab203b56 100644 --- a/vendor/golang.org/x/mod/modfile/rule.go +++ b/vendor/golang.org/x/mod/modfile/rule.go @@ -327,6 +327,7 @@ func parseToFile(file string, data []byte, fix VersionFixer, strict bool) (parse } var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?([a-z]+[0-9]+)?$`) + var laxGoVersionRE = lazyregexp.New(`^v?(([1-9][0-9]*)\.(0|[1-9][0-9]*))([^0-9].*)$`) // Toolchains must be named beginning with `go1`, @@ -1272,6 +1273,17 @@ func (f *File) SetRequire(req []*Require) { // SetRequireSeparateIndirect will split it into a direct-only and indirect-only // block. This aids in the transition to separate blocks. func (f *File) SetRequireSeparateIndirect(req []*Require) { + f.setRequireSeparateIndirect(req, false) +} + +// SetRequireAtMostTwo is like SetRequireSeparateIndirect but it aggressively +// consolidates all requirements into at most two blocks (one direct, one indirect). +// It ignores existing blocks and comments when deciding where to place requirements. +func (f *File) SetRequireAtMostTwo(req []*Require) { + f.setRequireSeparateIndirect(req, true) +} + +func (f *File) setRequireSeparateIndirect(req []*Require, simplify bool) { // hasComments returns whether a line or block has comments // other than "indirect". hasComments := func(c Comments) bool { @@ -1304,6 +1316,17 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { } // Examine existing require lines and blocks. + need := make(map[string]*Require) + for _, r := range req { + need[r.Mod.Path] = r + } + lineIndirect := make(map[*Line]bool) + for _, r := range f.Require { + if n := need[r.Mod.Path]; n != nil { + lineIndirect[r.Syntax] = n.Indirect + } + } + var ( // We may insert new requirements into the last uncommented // direct-only and indirect-only blocks. We may also move requirements @@ -1321,7 +1344,9 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // Track the block each requirement belongs to (if any) so we can // move them later. - lineToBlock = make(map[*Line]*LineBlock) + lineToBlock = make(map[*Line]*LineBlock) + directBlockComments []Comment + indirectBlockComments []Comment ) for i, stmt := range f.Syntax.Stmt { switch stmt := stmt.(type) { @@ -1364,6 +1389,24 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { if allIndirect { lastIndirectIndex = i } + if simplify { + anyDirect := false + for _, line := range stmt.Line { + if ind, ok := lineIndirect[line]; ok && !ind { + anyDirect = true + break + } + } + target := &directBlockComments + if !anyDirect && len(stmt.Line) > 0 { + target = &indirectBlockComments + } + if len(*target) > 0 && len(stmt.Comments.Before) > 0 { + *target = append(*target, Comment{Token: "//"}) + } + *target = append(*target, stmt.Comments.Before...) + stmt.Comments.Before = nil + } } } @@ -1422,6 +1465,15 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { lastIndirectBlock = ensureBlock(lastIndirectIndex) } + if simplify { + if len(directBlockComments) > 0 { + lastDirectBlock.Comments.Before = append(lastDirectBlock.Comments.Before, directBlockComments...) + } + if len(indirectBlockComments) > 0 { + lastIndirectBlock.Comments.Before = append(lastIndirectBlock.Comments.Before, indirectBlockComments...) + } + } + // Delete requirements we don't want anymore. // Update versions and indirect comments on requirements we want to keep. // If a requirement is in last{Direct,Indirect}Block with the wrong @@ -1430,10 +1482,6 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // correct block. // // Some blocks may be empty after this. Cleanup will remove them. - need := make(map[string]*Require) - for _, r := range req { - need[r.Mod.Path] = r - } have := make(map[string]*Require) for _, r := range f.Require { path := r.Mod.Path @@ -1446,10 +1494,10 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { r.setVersion(need[path].Mod.Version) r.setIndirect(need[path].Indirect) if need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { moveReq(r, lastIndirectBlock) } else if !need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { moveReq(r, lastDirectBlock) } } @@ -1736,8 +1784,7 @@ func removeDups(syntax *FileSyntax, exclude *[]*Exclude, replace *[]*Replace, to // Remove duplicate replacements. // Later replacements take priority over earlier ones. haveReplace := make(map[module.Version]bool) - for i := len(*replace) - 1; i >= 0; i-- { - x := (*replace)[i] + for _, x := range slices.Backward(*replace) { if haveReplace[x.Old] { kill[x.Syntax] = true continue diff --git a/vendor/golang.org/x/sync/semaphore/semaphore.go b/vendor/golang.org/x/sync/semaphore/semaphore.go index 040c5bc50..96a035aed 100644 --- a/vendor/golang.org/x/sync/semaphore/semaphore.go +++ b/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -24,7 +24,7 @@ func NewWeighted(n int64) *Weighted { } // Weighted provides a way to bound concurrent access to a resource. -// The callers can request access with a given weight. +// The callers can request access with a given non-negative weight. type Weighted struct { size int64 cur int64 @@ -32,10 +32,13 @@ type Weighted struct { waiters list.List } -// Acquire acquires the semaphore with a weight of n, blocking until resources +// Acquire acquires the semaphore with a non-negative weight of n, blocking until resources // are available or ctx is done. On success, returns nil. On failure, returns // ctx.Err() and leaves the semaphore unchanged. func (s *Weighted) Acquire(ctx context.Context, n int64) error { + if n < 0 { + panic("semaphore: n < 0") + } done := ctx.Done() s.mu.Lock() @@ -106,9 +109,12 @@ func (s *Weighted) Acquire(ctx context.Context, n int64) error { } } -// TryAcquire acquires the semaphore with a weight of n without blocking. +// TryAcquire acquires the semaphore with a non-negative weight of n without blocking. // On success, returns true. On failure, returns false and leaves the semaphore unchanged. func (s *Weighted) TryAcquire(n int64) bool { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() success := s.size-s.cur >= n && s.waiters.Len() == 0 if success { @@ -118,8 +124,11 @@ func (s *Weighted) TryAcquire(n int64) bool { return success } -// Release releases the semaphore with a weight of n. +// Release releases the semaphore with a non-negative weight of n. func (s *Weighted) Release(n int64) { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() s.cur -= n if s.cur < 0 { diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index ce4d7ab1e..21e2bfa39 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -1874,6 +1874,7 @@ func Dup2(oldfd, newfd int) error { //sys Dup3(oldfd int, newfd int, flags int) (err error) //sysnb EpollCreate1(flag int) (fd int, err error) //sysnb EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) +//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Eventfd(initval uint, flags int) (fd int, err error) = SYS_EVENTFD2 //sys Exit(code int) = SYS_EXIT_GROUP //sys Fallocate(fd int, mode uint32, off int64, len int64) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_386.go b/vendor/golang.org/x/sys/unix/syscall_linux_386.go index 506dafa7b..210d545c9 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_386.go @@ -20,7 +20,6 @@ func setTimeval(sec, usec int64) Timeval { // 64-bit file system and 32-bit uid calls // (386 default is 32-bit file system and 16-bit uid). -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64_64 //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go index d557cf8de..a9a52f231 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go index ecf92bfa2..54474c20f 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go @@ -44,7 +44,6 @@ func Seek(fd int, offset int64, whence int) (newoffset int64, err error) { // 64-bit file system and 32-bit uid calls // (16-bit uid calls are not always supported in newer kernels) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go index 173738077..e9f30db97 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go index a3fd1d0b8..6f09ca200 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go index 70963a95a..ca3b56597 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go index c218ebd28..54ba667b1 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go @@ -13,7 +13,6 @@ import ( func Syscall9(trap, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) (r1, r2 uintptr, err syscall.Errno) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Ftruncate(fd int, length int64) (err error) = SYS_FTRUNCATE64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go index e6c48500c..ce4628590 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go @@ -11,7 +11,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go index 7286a9aa8..33f7af380 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go index fc5543c5f..c658871e3 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go index 66f31210d..2c8587691 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go @@ -10,7 +10,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go index 11d1f1698..4964119af 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 9d72a6b73..5bb51d7ae 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -1359,6 +1359,7 @@ const ( FAN_UNLIMITED_MARKS = 0x20 FAN_UNLIMITED_QUEUE = 0x10 FD_CLOEXEC = 0x1 + FD_PIDFS_ROOT = -0x2712 FD_SETSIZE = 0x400 FF0 = 0x0 FIB_RULE_DEV_DETACHED = 0x8 @@ -1970,6 +1971,8 @@ const ( MADV_DONTNEED = 0x4 MADV_DONTNEED_LOCKED = 0x18 MADV_FREE = 0x8 + MADV_GUARD_INSTALL = 0x66 + MADV_GUARD_REMOVE = 0x67 MADV_HUGEPAGE = 0xe MADV_HWPOISON = 0x64 MADV_KEEPONFORK = 0x13 @@ -2114,7 +2117,7 @@ const ( MS_NOSEC = 0x10000000 MS_NOSUID = 0x2 MS_NOSYMFOLLOW = 0x100 - MS_NOUSER = -0x80000000 + MS_NOUSER = 0x80000000 MS_POSIXACL = 0x10000 MS_PRIVATE = 0x40000 MS_RDONLY = 0x1 @@ -3786,6 +3789,9 @@ const ( TCPOPT_TIMESTAMP = 0x8 TCPOPT_TSTAMP_HDR = 0x101080a TCPOPT_WINDOW = 0x3 + TCP_AO_KEYF_EXCLUDE_OPT = 0x2 + TCP_AO_KEYF_IFINDEX = 0x1 + TCP_AO_MAXKEYLEN = 0x50 TCP_CC_INFO = 0x1a TCP_CM_INQ = 0x24 TCP_CONGESTION = 0xd diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux.go b/vendor/golang.org/x/sys/unix/zsyscall_linux.go index 80f40e401..5788c2a58 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux.go @@ -700,6 +700,23 @@ func EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { + var _p0 unsafe.Pointer + if len(events) > 0 { + _p0 = unsafe.Pointer(&events[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func Eventfd(initval uint, flags int) (fd int, err error) { r0, _, e1 := Syscall(SYS_EVENTFD2, uintptr(initval), uintptr(flags), 0) fd = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go index 4def3e9fc..254f33988 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64_64, uintptr(fd), uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go index fef2bc8ba..27c05db1a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go index a9fd76a88..840d85bfc 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go @@ -213,23 +213,6 @@ func sendmsg(s int, msg *Msghdr, flags int) (n int, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN32, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go index 460065028..fe414498b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go index c8987d264..eb358ce05 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go index 921f43061..c437622f1 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset>>32), uintptr(offset), uintptr(length>>32), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go index 44f067829..bc4ca2558 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go index e7fa0abf0..5051435ce 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go index 8c5125675..33aa5418a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go index 7392fd45e..3bef8ef1d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go index 41180434e..fc1bd4e2c 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go index 40c6ce7ae..d78fe7dab 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go index 2cfe34adb..76dcf87d0 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go index 61e6f0709..2cf020f2b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go index 834b84204..527637623 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/windows/security_windows.go b/vendor/golang.org/x/sys/windows/security_windows.go index 6c955cea1..783621561 100644 --- a/vendor/golang.org/x/sys/windows/security_windows.go +++ b/vendor/golang.org/x/sys/windows/security_windows.go @@ -1109,17 +1109,53 @@ const ( ) // This type is the union inside of TRUSTEE and must be created using one of the TrusteeValueFrom* functions. +// +// Go pointers stored in a TrusteeValue must be pinned using [runtime.Pinner] +// for the lifetime of the TrusteeValue. type TrusteeValue uintptr +// TrusteeValueFromString is unsafe and should not be used. +// +// It returns a uintptr containing a reference to newly-allocated memory +// which will be freed by the garbage collector. +// There is no way for the caller to safely reference this memory. +// +// To create a [TrusteeValue] from a string, use: +// +// p, err := windows.UTF16PtrFromString(s) +// if err != nil { +// // handle error +// } +// +// // Pin the string for as long as it is used. +// var pinner runtime.Pinner +// pinner.Pin(p) +// defer pinner.Unpin() +// +// tv := TrusteeValue(unsafe.Pointer(p)) +// +// Deprecated: TrusteeValueFromString is unsafe and should not be used. func TrusteeValueFromString(str string) TrusteeValue { return TrusteeValue(unsafe.Pointer(StringToUTF16Ptr(str))) } + +// TrusteeValueFromSID returns a [TrusteeValue] referencing sid. +// +// The caller must pin sid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromSID(sid *SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(sid)) } + +// TrusteeValueFromObjectsAndSid returns a [TrusteeValue] referencing objectsAndSid. +// +// The caller must pin objectsAndSid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndSid(objectsAndSid *OBJECTS_AND_SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndSid)) } + +// TrusteeValueFromObjectsAndName returns a [TrusteeValue] referencing objectsAndName. +// +// The caller must pin objectsAndName using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndName(objectsAndName *OBJECTS_AND_NAME) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndName)) } diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index 9755bca9f..e6966b4c3 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -1728,11 +1728,15 @@ func (s *NTUnicodeString) String() string { // the more common *uint16 string type. func NewNTString(s string) (*NTString, error) { var nts NTString - s8, err := BytePtrFromString(s) + s8, err := ByteSliceFromString(s) if err != nil { return nil, err } - RtlInitString(&nts, s8) + // The source string plus its terminating NUL must fit within MAX_USHORT. + if len(s8) > MAX_USHORT { + return nil, syscall.EINVAL + } + RtlInitString(&nts, &s8[0]) return &nts, nil } diff --git a/vendor/golang.org/x/sys/windows/types_windows.go b/vendor/golang.org/x/sys/windows/types_windows.go index d2574a73e..75a50b316 100644 --- a/vendor/golang.org/x/sys/windows/types_windows.go +++ b/vendor/golang.org/x/sys/windows/types_windows.go @@ -169,6 +169,7 @@ const ( FORMAT_MESSAGE_ARGUMENT_ARRAY = 8192 FORMAT_MESSAGE_MAX_WIDTH_MASK = 255 + MAX_USHORT = 0xffff MAX_PATH = 260 MAX_LONG_PATH = 32768 diff --git a/vendor/golang.org/x/text/cases/context.go b/vendor/golang.org/x/text/cases/context.go index e9aa9e193..a28f45d7b 100644 --- a/vendor/golang.org/x/text/cases/context.go +++ b/vendor/golang.org/x/text/cases/context.go @@ -249,7 +249,7 @@ func upper(c *context) bool { return c.copy() } -// isUpper writes the isUppercase version of the current rune to dst. +// isUpper reports whether the current rune is in upper case. func isUpper(c *context) bool { ct := c.caseType() if c.info&hasMappingMask == 0 || ct == cUpper { diff --git a/vendor/golang.org/x/text/cases/map.go b/vendor/golang.org/x/text/cases/map.go index 0f7c6a14b..51a683092 100644 --- a/vendor/golang.org/x/text/cases/map.go +++ b/vendor/golang.org/x/text/cases/map.go @@ -774,7 +774,7 @@ func nlTitle(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' && c.src[c.pSrc] != 'i' { return title(c) @@ -794,7 +794,7 @@ func nlTitleSpan(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' { return isTitle(c) diff --git a/vendor/golang.org/x/text/unicode/norm/forminfo.go b/vendor/golang.org/x/text/unicode/norm/forminfo.go index f3a234e5f..b3cf5d9bd 100644 --- a/vendor/golang.org/x/text/unicode/norm/forminfo.go +++ b/vendor/golang.org/x/text/unicode/norm/forminfo.go @@ -121,8 +121,12 @@ func (p Properties) BoundaryAfter() bool { // // When all 6 bits are zero, the character is inert, meaning it is never // influenced by normalization. +// +// We set flags to 0x80 (high bit 7 unused in quick check data) to indicate an invalid rune. type qcInfo uint8 +func (p Properties) isInvalid() bool { return p.flags == 0x80 } + func (p Properties) isYesC() bool { return p.flags&0x10 == 0 } func (p Properties) isYesD() bool { return p.flags&0x4 == 0 } @@ -247,6 +251,9 @@ func (f Form) PropertiesString(s string) Properties { // to a Properties. See the comment at the top of the file // for more information on the format. func compInfo(v uint16, sz int) Properties { + if sz == 0 { + return Properties{flags: 0x80, size: 1} + } if v == 0 { return Properties{size: uint8(sz)} } else if v >= 0x8000 { @@ -254,7 +261,7 @@ func compInfo(v uint16, sz int) Properties { size: uint8(sz), ccc: uint8(v), tccc: uint8(v), - flags: qcInfo(v >> 8), + flags: qcInfo(v>>8) & 0x3f, } if p.ccc > 0 || p.combinesBackward() { p.nLead = uint8(p.flags & 0x3) diff --git a/vendor/golang.org/x/text/unicode/norm/iter.go b/vendor/golang.org/x/text/unicode/norm/iter.go index 417c6b268..3cc059224 100644 --- a/vendor/golang.org/x/text/unicode/norm/iter.go +++ b/vendor/golang.org/x/text/unicode/norm/iter.go @@ -376,16 +376,12 @@ func nextComposed(i *Iter) []byte { goto doNorm } prevCC = i.info.tccc - sz := int(i.info.size) - if sz == 0 { - sz = 1 // illegal rune: copy byte-by-byte - } - p := outp + sz + p := outp + int(i.info.size) if p > len(i.buf) { break } outp = p - i.p += sz + i.p += int(i.info.size) if i.p >= i.rb.nsrc { i.setDone() break diff --git a/vendor/golang.org/x/text/unicode/norm/normalize.go b/vendor/golang.org/x/text/unicode/norm/normalize.go index 4747ad07a..60b1511ca 100644 --- a/vendor/golang.org/x/text/unicode/norm/normalize.go +++ b/vendor/golang.org/x/text/unicode/norm/normalize.go @@ -148,7 +148,7 @@ func (f Form) IsNormalString(s string) bool { // patched buffer and whether the decomposition is still in progress. func patchTail(rb *reorderBuffer) bool { info, p := lastRuneStart(&rb.f, rb.out) - if p == -1 || info.size == 0 { + if p == -1 || info.isInvalid() { return true } end := p + int(info.size) @@ -225,7 +225,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { } fd := &rb.f if doMerge { - var info Properties + info := Properties{flags: 0x80, size: 1} // invalid rune if p < n { info = fd.info(src, p) if !info.BoundaryBefore() || info.nLeadingNonStarters() > 0 { @@ -235,7 +235,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { p = decomposeSegment(rb, p, true) } } - if info.size == 0 { + if info.isInvalid() { rb.doFlush() // Append incomplete UTF-8 encoding. return src.appendSlice(rb.out, p, n) @@ -314,7 +314,7 @@ func (f *formInfo) quickSpan(src input, i, end int, atEOF bool) (n int, ok bool) continue } info := f.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { // include incomplete runes return n, true @@ -379,7 +379,7 @@ func (f Form) firstBoundary(src input, nsrc int) int { // CGJ insertion points correctly. Luckily it doesn't have to. for { info := fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { return -1 } if s := ss.next(info); s != ssSuccess { @@ -424,7 +424,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { } fd := formTable[f] info := fd.info(src, 0) - if info.size == 0 { + if info.isInvalid() { if atEOF { return 1 } @@ -435,7 +435,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { for i := int(info.size); i < nsrc; i += int(info.size) { info = fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { return i } @@ -465,7 +465,7 @@ func lastBoundary(fd *formInfo, b []byte) int { if p == -1 { return -1 } - if info.size == 0 { // ends with incomplete rune + if info.isInvalid() { // ends with incomplete rune if p == 0 { // starts with incomplete rune return -1 } @@ -504,7 +504,7 @@ func lastBoundary(fd *formInfo, b []byte) int { func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { // Force one character to be consumed. info := rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { return 0 } if s := rb.ss.next(info); s == ssStarter { @@ -528,7 +528,7 @@ func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { break } info = rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { if !atEOF { return int(iShortSrc) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 2c60d9282..005d79a73 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -48,7 +48,7 @@ github.com/fatih/color # github.com/gdamore/encoding v1.0.1 ## explicit; go 1.9 github.com/gdamore/encoding -# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 +# github.com/gdamore/tcell/v3 v3.4.1 ## explicit; go 1.25.0 github.com/gdamore/tcell/v3 github.com/gdamore/tcell/v3/color @@ -175,27 +175,25 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.36.0 +# golang.org/x/mod v0.37.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile golang.org/x/mod/module golang.org/x/mod/semver -# golang.org/x/net v0.55.0 -## explicit; go 1.25.0 -# golang.org/x/sync v0.21.0 +# golang.org/x/sync v0.22.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup golang.org/x/sync/semaphore -# golang.org/x/sys v0.46.0 +# golang.org/x/sys v0.47.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.44.0 +# golang.org/x/term v0.45.0 ## explicit; go 1.25.0 golang.org/x/term -# golang.org/x/text v0.38.0 +# golang.org/x/text v0.40.0 ## explicit; go 1.25.0 golang.org/x/text/cases golang.org/x/text/encoding @@ -208,7 +206,7 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.45.0 +# golang.org/x/tools v0.47.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c