Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4011643dd | ||
|
|
f8b8307a29 | ||
|
|
471fe313d8 | ||
|
|
9adf4a1908 | ||
|
|
4df7646654 | ||
|
|
c7c4a375a9 | ||
|
|
a2fd3541d5 | ||
|
|
15ca38ba2e | ||
|
|
e0ae134ee4 | ||
|
|
1d90e1b565 | ||
|
|
1b09674ce8 | ||
|
|
d13a648132 | ||
|
|
bed185eb28 | ||
|
|
84a1992055 | ||
|
|
7f85bf5563 | ||
|
|
3e21143a0e | ||
|
|
fa2e7ae1e7 | ||
|
|
5a3f81d1f7 | ||
|
|
ebbdf829e7 | ||
|
|
5e6e1617aa | ||
|
|
5e9cfab283 | ||
|
|
ca7cfc3232 | ||
|
|
dc765c4166 | ||
|
|
c8cc18920f | ||
|
|
ce3bcfe37c | ||
|
|
f4ddf2f0d4 | ||
|
|
54b1bc31cd | ||
|
|
eb57e3ead0 | ||
|
|
0caa391c4d | ||
|
|
0c6bdac2f7 | ||
|
|
257e222f8d | ||
|
|
874e230aef | ||
|
|
4da5795ef1 | ||
|
|
03c9acad26 | ||
|
|
d53322675d | ||
|
|
ae18ad5b66 | ||
|
|
b70075eba6 | ||
|
|
e413c216ba | ||
|
|
14b9a0b647 | ||
|
|
58bdcbf1dd | ||
|
|
88d685df53 | ||
|
|
61ccc1efd2 | ||
|
|
5b7dd9e43c | ||
|
|
4ab5e54139 | ||
|
|
ab84410b41 | ||
|
|
a78cbf4882 | ||
|
|
62a7d9bbcc | ||
|
|
555d8bbc96 | ||
|
|
ad23bd03a0 | ||
|
|
1f923bdc4b | ||
|
|
b5a8ecf786 | ||
|
|
3e80a9e886 | ||
|
|
9706416a41 | ||
|
|
56f2ecb06c | ||
|
|
d7c79ba20b | ||
|
|
b6fb7f1365 | ||
|
|
dbb8b17d83 | ||
|
|
d019626342 | ||
|
|
595aca2a4b | ||
|
|
2691477aff | ||
|
|
8ca71eeb36 | ||
|
|
d3a3c8d87d | ||
|
|
ee622d044e | ||
|
|
99035959a1 | ||
|
|
0092c9d08d | ||
|
|
befa35645e | ||
|
|
7a690f9078 | ||
|
|
dafac52a4c | ||
|
|
1c84f77319 | ||
|
|
8d8bdb948b | ||
|
|
cdcfeb396f | ||
|
|
f5b9ad8c00 | ||
|
|
8263d15b03 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -26,7 +26,7 @@ If applicable, add screenshots to help explain your problem.
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Windows]
|
||||
- Lazygit Version [e.g. v0.1.45]
|
||||
- The last commit id if you built project from sources (run : ```git-rev parse HEAD```)
|
||||
- The last commit id if you built project from sources (run : ```git rev-parse HEAD```)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -100,3 +100,6 @@ jobs:
|
||||
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
|
||||
exit 1
|
||||
fi
|
||||
- name: errors
|
||||
run: golangci-lint run
|
||||
if: ${{ failure() }}
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -18,19 +18,21 @@ coverage.txt
|
||||
|
||||
# Binaries
|
||||
lazygit
|
||||
lazygit.exe
|
||||
|
||||
# Exceptions
|
||||
!.gitignore
|
||||
!.goreleaser.yml
|
||||
!.circleci/
|
||||
!.github/
|
||||
# these are for our integration tests
|
||||
!.git_keep
|
||||
!.gitmodules_keep
|
||||
|
||||
test/git_server/data
|
||||
test/integration/*/actual/
|
||||
test/integration/*/actual_remote/
|
||||
test/integration/*/used_config/
|
||||
# these sample hooks waste too much space
|
||||
test/integration/*/expected/.git_keep/hooks/
|
||||
test/integration/*/expected_remote/hooks/
|
||||
!.git_keep/
|
||||
lazygit.exe
|
||||
test/integration/*/expected/**/hooks/
|
||||
test/integration/*/expected_remote/**/hooks/
|
||||
|
||||
@@ -1,74 +1,3 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
# Lazygit Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the [project leader](https://github.com/jesseduffield).
|
||||
All complaints will be reviewed and investigated and will result in a response that
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
Be nice, or face the wrath of the maintainer.
|
||||
|
||||
@@ -6,7 +6,7 @@ When contributing to this repository, please first discuss the change you wish
|
||||
to make via issue, email, or any other method with the owners of this repository
|
||||
before making a change.
|
||||
|
||||
## So all code changes happen through Pull Requests
|
||||
## All code changes happen through Pull Requests
|
||||
|
||||
Pull requests are the best way to propose changes to the codebase. We actively
|
||||
welcome your pull requests:
|
||||
@@ -14,10 +14,10 @@ welcome your pull requests:
|
||||
1. Fork the repo and create your branch from `master`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
3. If you've added code that need documentation, update the documentation.
|
||||
4. Make sure your code follows the [effective go](https://golang.org/doc/effective_go.html) guidelines as much as possible.
|
||||
5. Be sure to test your modifications.
|
||||
6. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
7. Issue that pull request!
|
||||
4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
5. Issue that pull request!
|
||||
|
||||
If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
@@ -36,9 +36,60 @@ covers the project. Feel free to contact the maintainers if that's a concern.
|
||||
We use GitHub issues to track public bugs. Report a bug by [opening a new
|
||||
issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy!
|
||||
|
||||
## Go
|
||||
|
||||
This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently:
|
||||
|
||||
1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it
|
||||
2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface.
|
||||
3. If a struct implements an interface, we make it explicit with something like:
|
||||
|
||||
```go
|
||||
var _ MyInterface = &MyStruct{}
|
||||
```
|
||||
|
||||
This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing.
|
||||
|
||||
## Internationalisation
|
||||
|
||||
Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package. If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Although it is appreciated if you translate the text into other languages, it's not expected of you (google translate will likely do a bad job anyway!).
|
||||
|
||||
## Debugging
|
||||
|
||||
The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`).
|
||||
|
||||
From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")`
|
||||
|
||||
If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself.
|
||||
|
||||
If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy!
|
||||
|
||||
If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the 'console' key is not, at the time of writing, in a stable release.
|
||||
|
||||
```jsonc
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "debug lazygit",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "main.go",
|
||||
"console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. Lazygit has its own integration test system where you can build a sandbox repo with a shell script, record yourself doing something, and commit the resulting repo snapshot. It's pretty damn cool! To learn more see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Integration_Tests.md)
|
||||
|
||||
## Updating Gocui
|
||||
|
||||
Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rending windows and handling user input. Here's the typical process to follow:
|
||||
Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow:
|
||||
|
||||
1. Make the changes in gocui inside the vendor directory so it's easy to test against lazygit
|
||||
2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`)
|
||||
@@ -50,3 +101,7 @@ Sometimes you will need to make a change in the gocui fork (https://github.com/j
|
||||
```
|
||||
|
||||
5. Raise a PR in lazygit with those changes
|
||||
|
||||
## Improvements
|
||||
|
||||
If you can think of any way to improve these docs let us know.
|
||||
|
||||
@@ -169,7 +169,7 @@ conda install -c conda-forge lazygit
|
||||
### Go
|
||||
|
||||
```sh
|
||||
go get github.com/jesseduffield/lazygit
|
||||
go install github.com/jesseduffield/lazygit@latest
|
||||
```
|
||||
|
||||
Please note:
|
||||
|
||||
@@ -44,7 +44,7 @@ gui:
|
||||
show: true
|
||||
mouseEvents: true
|
||||
skipUnstageLineWarning: false
|
||||
skipStashWarning: true
|
||||
skipStashWarning: false
|
||||
showFileTree: true # for rendering changes files in a tree format
|
||||
showListFooter: true # for seeing the '5 of 20' message in list panels
|
||||
showRandomTip: true
|
||||
@@ -205,6 +205,7 @@ keybinding:
|
||||
resetCherryPick: '<c-R>'
|
||||
copyCommitMessageToClipboard: '<c-y>'
|
||||
openLogMenu: '<c-l>'
|
||||
viewBisectOptions: 'b'
|
||||
stash:
|
||||
popStash: 'g'
|
||||
commitFiles:
|
||||
@@ -389,6 +390,7 @@ gui:
|
||||
```
|
||||
|
||||
You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
authorColors:
|
||||
@@ -398,6 +400,16 @@ gui:
|
||||
'*': '#0000ff'
|
||||
```
|
||||
|
||||
## Custom Branch Color
|
||||
|
||||
You can customize the color of branches based on the branch prefix:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
branchColors:
|
||||
'docs': '#11aaff' # use a light blue for branches beginning with 'docs/'
|
||||
```
|
||||
|
||||
## Example Coloring
|
||||
|
||||

|
||||
|
||||
@@ -69,6 +69,7 @@ For a given custom command, here are the allowed fields:
|
||||
| prompts | a list of prompts that will request user input before running the final command | no |
|
||||
| loadingText | text to display while waiting for command to finish | no |
|
||||
| description | text to display in the keybindings menu that appears when you press 'x' | no |
|
||||
| stream | whether you want to stream the command's output to the Command Log panel | no |
|
||||
|
||||
### Contexts
|
||||
|
||||
|
||||
@@ -33,17 +33,32 @@ git commit -am "myfile1"
|
||||
|
||||
## Running tests
|
||||
|
||||
### From a TUI
|
||||
|
||||
You can run/record/sandbox tests via a TUI with the following command:
|
||||
|
||||
```
|
||||
go run test/lazyintegration/main.go
|
||||
```
|
||||
|
||||
This TUI makes much of the following documentation redundant, but feel free to read through anyway!
|
||||
|
||||
### From command line
|
||||
|
||||
To run all tests - assuming you're at the project root:
|
||||
|
||||
```
|
||||
go test ./pkg/gui/
|
||||
```
|
||||
|
||||
To run them in parallel
|
||||
|
||||
```
|
||||
PARALLEL=true go test ./pkg/gui
|
||||
```
|
||||
|
||||
To run a single test
|
||||
|
||||
```
|
||||
go test ./pkg/gui -run /<test name>
|
||||
# For example, to run the `tags` test:
|
||||
@@ -51,29 +66,35 @@ go test ./pkg/gui -run /tags
|
||||
```
|
||||
|
||||
To run a test at a certain speed
|
||||
|
||||
```
|
||||
SPEED=2 go test ./pkg/gui -run /<test name>
|
||||
```
|
||||
|
||||
To update a snapshot
|
||||
|
||||
```
|
||||
UPDATE_SNAPSHOTS=true go test ./pkg/gui -run /<test name>
|
||||
MODE=updateSnapshot go test ./pkg/gui -run /<test name>
|
||||
```
|
||||
|
||||
## Creating a new test
|
||||
|
||||
To create a new test:
|
||||
1) Copy and paste an existing test directory and rename the new directory to whatever you want the test name to be. Update the test.json file's description to describe your test.
|
||||
2) Update the `setup.sh` any way you like
|
||||
3) If you want to have a config folder for just that test, create a `config` directory to contain a `config.yml` and optionally a `state.yml` file. Otherwise, the `test/default_test_config` directory will be used.
|
||||
4) From the lazygit root directory, run:
|
||||
|
||||
1. Copy and paste an existing test directory and rename the new directory to whatever you want the test name to be. Update the test.json file's description to describe your test.
|
||||
2. Update the `setup.sh` any way you like
|
||||
3. If you want to have a config folder for just that test, create a `config` directory to contain a `config.yml` and optionally a `state.yml` file. Otherwise, the `test/default_test_config` directory will be used.
|
||||
4. From the lazygit root directory, run:
|
||||
|
||||
```
|
||||
RECORD_EVENTS=true go test ./pkg/gui -run /<test name>
|
||||
MODE=record go test ./pkg/gui -run /<test name>
|
||||
```
|
||||
5) Feel free to re-attempt recording as many times as you like. In the absence of a proper testing framework, the more deliberate your keypresses, the better!
|
||||
6) Once satisfied with the recording, stage all the newly created files: `test.json`, `setup.sh`, `recording.json` and the `expected` directory that contains a copy of the repo you created.
|
||||
|
||||
5. Feel free to re-attempt recording as many times as you like. In the absence of a proper testing framework, the more deliberate your keypresses, the better!
|
||||
6. Once satisfied with the recording, stage all the newly created files: `test.json`, `setup.sh`, `recording.json` and the `expected` directory that contains a copy of the repo you created.
|
||||
|
||||
The resulting directory will look like:
|
||||
|
||||
```
|
||||
actual/ (the resulting repo after running the test, ignored by git)
|
||||
expected/ (the 'snapshot' repo)
|
||||
@@ -85,6 +106,14 @@ recording.json
|
||||
|
||||
Feel free to create a hierarchy of directories in the `test/integration` directory to group tests by feature.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
The integration tests serve a secondary purpose of providing a setup for easy sandboxing. If you want to run a test in sandbox mode (meaning the session won't be recorded and we won't create/update snapshots), go:
|
||||
|
||||
```
|
||||
MODE=sandbox go test ./pkg/gui -run /<test name>
|
||||
```
|
||||
|
||||
## Feedback
|
||||
|
||||
If you think this process can be improved, let me know! It shouldn't be too hard to change things.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root.
|
||||
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
|
||||
|
||||
# Lazygit Keybindings
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<kbd>ctrl+r</kbd>: reset cherry-picked (copied) commits selection
|
||||
<kbd>ctrl+y</kbd>: copy commit message to clipboard
|
||||
<kbd>o</kbd>: open commit in browser
|
||||
<kbd>b</kbd>: view bisect options
|
||||
</pre>
|
||||
|
||||
## Commits Panel (Reflog Tab)
|
||||
@@ -206,7 +207,7 @@
|
||||
<pre>
|
||||
<kbd>ctrl+o</kbd>: copy submodule name to clipboard
|
||||
<kbd>enter</kbd>: enter submodule
|
||||
<kbd>d</kbd>: view reset and remove submodule options
|
||||
<kbd>d</kbd>: remove submodule
|
||||
<kbd>u</kbd>: update submodule
|
||||
<kbd>n</kbd>: add new submodule
|
||||
<kbd>e</kbd>: update submodule URL
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root.
|
||||
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
|
||||
|
||||
# Lazygit Sneltoetsen
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<kbd>ctrl+r</kbd>: reset cherry-picked (gekopieerde) commits selectie
|
||||
<kbd>ctrl+y</kbd>: kopieer commit bericht naar klembord
|
||||
<kbd>o</kbd>: open commit in browser
|
||||
<kbd>b</kbd>: view bisect options
|
||||
</pre>
|
||||
|
||||
## Commits Paneel (Reflog Tabblad)
|
||||
@@ -206,7 +207,7 @@
|
||||
<pre>
|
||||
<kbd>ctrl+o</kbd>: kopieer submodule naam naar klembord
|
||||
<kbd>enter</kbd>: enter submodule
|
||||
<kbd>d</kbd>: bekijk reset en verwijder submodule opties
|
||||
<kbd>d</kbd>: remove submodule
|
||||
<kbd>u</kbd>: update submodule
|
||||
<kbd>n</kbd>: voeg nieuwe submodule toe
|
||||
<kbd>e</kbd>: update submodule URL
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root.
|
||||
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
|
||||
|
||||
# Lazygit Keybindings
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<kbd>ctrl+r</kbd>: reset cherry-picked (copied) commits selection
|
||||
<kbd>ctrl+y</kbd>: copy commit message to clipboard
|
||||
<kbd>o</kbd>: open commit in browser
|
||||
<kbd>b</kbd>: view bisect options
|
||||
</pre>
|
||||
|
||||
## Commity Panel (Reflog Tab)
|
||||
@@ -206,7 +207,7 @@
|
||||
<pre>
|
||||
<kbd>ctrl+o</kbd>: copy submodule name to clipboard
|
||||
<kbd>enter</kbd>: enter submodule
|
||||
<kbd>d</kbd>: view reset and remove submodule options
|
||||
<kbd>d</kbd>: remove submodule
|
||||
<kbd>u</kbd>: update submodule
|
||||
<kbd>n</kbd>: add new submodule
|
||||
<kbd>e</kbd>: update submodule URL
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root.
|
||||
_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
|
||||
|
||||
# Lazygit 按键绑定
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<kbd>ctrl+r</kbd>: 重置已拣选(复制)的提交
|
||||
<kbd>ctrl+y</kbd>: 将提交消息复制到剪贴板
|
||||
<kbd>o</kbd>: open commit in browser
|
||||
<kbd>b</kbd>: view bisect options
|
||||
</pre>
|
||||
|
||||
## 提交 面板 (Reflog)
|
||||
@@ -206,7 +207,7 @@
|
||||
<pre>
|
||||
<kbd>ctrl+o</kbd>: 将子模块名称复制到剪贴板
|
||||
<kbd>enter</kbd>: 输入子模块
|
||||
<kbd>d</kbd>: 查看重置和删除子模块选项
|
||||
<kbd>d</kbd>: 删除子模块
|
||||
<kbd>u</kbd>: 更新子模块
|
||||
<kbd>n</kbd>: 添加新的子模块
|
||||
<kbd>e</kbd>: 更新子模块 URL
|
||||
|
||||
2
go.mod
2
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/creack/pty v1.1.11
|
||||
github.com/fatih/color v1.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.4.7
|
||||
github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b // indirect
|
||||
github.com/go-errors/errors v1.4.1
|
||||
github.com/go-logfmt/logfmt v0.5.0 // indirect
|
||||
github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3
|
||||
@@ -35,6 +36,7 @@ require (
|
||||
github.com/onsi/gomega v1.7.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/sahilm/fuzzy v0.1.0
|
||||
github.com/sanity-io/litter v1.5.2
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
|
||||
github.com/stretchr/testify v1.7.0
|
||||
|
||||
7
go.sum
7
go.sum
@@ -18,6 +18,7 @@ github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -34,6 +35,8 @@ github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdk
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM=
|
||||
github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
|
||||
github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b h1:eoaSI4eEwM5eTx/HvmRSwmicxuMhL73AyoEfM1oCJLc=
|
||||
github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b/go.mod h1:ZPwXnysybtQqdqKcWMWXux9aGdtMHe+kr+cwEZEe+A4=
|
||||
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
|
||||
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -124,6 +127,7 @@ github.com/onsi/gomega v1.7.1 h1:K0jcRCwNQM3vFGh1ppMtDh/+7ApJrjldlX8fA0jDTLQ=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -131,6 +135,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
|
||||
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/sanity-io/litter v1.5.2 h1:AnC8s9BMORWH5a4atZ4D6FPVvKGzHcnc5/IVTa87myw=
|
||||
github.com/sanity-io/litter v1.5.2/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
@@ -140,6 +146,7 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
||||
@@ -51,8 +51,8 @@ func generateAtDir(cheatsheetDir string) {
|
||||
|
||||
bindingSections := getBindingSections(mApp)
|
||||
content := formatSections(mApp.Tr, bindingSections)
|
||||
content = fmt.Sprintf("# This file is auto-generated. To update, make the changes in the "+
|
||||
"pkg/i18n directory and then run `%s` from the project root.\n\n%s", CommandToRun(), content)
|
||||
content = fmt.Sprintf("_This file is auto-generated. To update, make the changes in the "+
|
||||
"pkg/i18n directory and then run `%s` from the project root._\n\n%s", CommandToRun(), content)
|
||||
writeString(file, content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type GitCommand struct {
|
||||
Sync *git_commands.SyncCommands
|
||||
Tag *git_commands.TagCommands
|
||||
WorkingTree *git_commands.WorkingTreeCommands
|
||||
Bisect *git_commands.BisectCommands
|
||||
|
||||
Loaders Loaders
|
||||
}
|
||||
@@ -92,32 +93,28 @@ func NewGitCommandAux(
|
||||
// This is admittedly messy, but allows us to test each command struct in isolation,
|
||||
// and allows for better namespacing when compared to having every method living
|
||||
// on the one struct.
|
||||
// common ones are: cmn, osCommand, dotGitDir, configCommands
|
||||
configCommands := git_commands.NewConfigCommands(cmn, gitConfig, repo)
|
||||
statusCommands := git_commands.NewStatusCommands(cmn, osCommand, repo, dotGitDir)
|
||||
gitCommon := git_commands.NewGitCommon(cmn, cmd, osCommand, dotGitDir, repo, configCommands)
|
||||
|
||||
statusCommands := git_commands.NewStatusCommands(gitCommon)
|
||||
fileLoader := loaders.NewFileLoader(cmn, cmd, configCommands)
|
||||
flowCommands := git_commands.NewFlowCommands(cmn, cmd, configCommands)
|
||||
remoteCommands := git_commands.NewRemoteCommands(cmn, cmd)
|
||||
branchCommands := git_commands.NewBranchCommands(cmn, cmd)
|
||||
syncCommands := git_commands.NewSyncCommands(cmn, cmd)
|
||||
tagCommands := git_commands.NewTagCommands(cmn, cmd)
|
||||
commitCommands := git_commands.NewCommitCommands(cmn, cmd)
|
||||
customCommands := git_commands.NewCustomCommands(cmn, cmd)
|
||||
fileCommands := git_commands.NewFileCommands(cmn, cmd, configCommands, osCommand)
|
||||
submoduleCommands := git_commands.NewSubmoduleCommands(cmn, cmd, dotGitDir)
|
||||
workingTreeCommands := git_commands.NewWorkingTreeCommands(cmn, cmd, submoduleCommands, osCommand, fileLoader)
|
||||
rebaseCommands := git_commands.NewRebaseCommands(
|
||||
cmn,
|
||||
cmd,
|
||||
osCommand,
|
||||
commitCommands,
|
||||
workingTreeCommands,
|
||||
configCommands,
|
||||
dotGitDir,
|
||||
)
|
||||
stashCommands := git_commands.NewStashCommands(cmn, cmd, osCommand, fileLoader, workingTreeCommands)
|
||||
flowCommands := git_commands.NewFlowCommands(gitCommon)
|
||||
remoteCommands := git_commands.NewRemoteCommands(gitCommon)
|
||||
branchCommands := git_commands.NewBranchCommands(gitCommon)
|
||||
syncCommands := git_commands.NewSyncCommands(gitCommon)
|
||||
tagCommands := git_commands.NewTagCommands(gitCommon)
|
||||
commitCommands := git_commands.NewCommitCommands(gitCommon)
|
||||
customCommands := git_commands.NewCustomCommands(gitCommon)
|
||||
fileCommands := git_commands.NewFileCommands(gitCommon)
|
||||
submoduleCommands := git_commands.NewSubmoduleCommands(gitCommon)
|
||||
workingTreeCommands := git_commands.NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader)
|
||||
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
|
||||
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
|
||||
// TODO: have patch manager take workingTreeCommands in its entirety
|
||||
patchManager := patch.NewPatchManager(cmn.Log, workingTreeCommands.ApplyPatch, workingTreeCommands.ShowFileDiff)
|
||||
patchCommands := git_commands.NewPatchCommands(cmn, cmd, rebaseCommands, commitCommands, configCommands, statusCommands, patchManager)
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchManager)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
return &GitCommand{
|
||||
Branch: branchCommands,
|
||||
@@ -134,9 +131,10 @@ func NewGitCommandAux(
|
||||
Submodule: submoduleCommands,
|
||||
Sync: syncCommands,
|
||||
Tag: tagCommands,
|
||||
Bisect: bisectCommands,
|
||||
WorkingTree: workingTreeCommands,
|
||||
Loaders: Loaders{
|
||||
Branches: loaders.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchName),
|
||||
Branches: loaders.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchName, configCommands),
|
||||
CommitFiles: loaders.NewCommitFileLoader(cmn, cmd),
|
||||
Commits: loaders.NewCommitLoader(cmn, cmd, dotGitDir, branchCommands.CurrentBranchName, statusCommands.RebaseMode),
|
||||
Files: fileLoader,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -21,27 +18,7 @@ func (self *gitCmdObjRunner) Run(cmdObj oscommands.ICmdObj) error {
|
||||
}
|
||||
|
||||
func (self *gitCmdObjRunner) RunWithOutput(cmdObj oscommands.ICmdObj) (string, error) {
|
||||
// TODO: have this retry logic in other places we run the command
|
||||
waitTime := 50 * time.Millisecond
|
||||
retryCount := 5
|
||||
attempt := 0
|
||||
|
||||
for {
|
||||
output, err := self.innerRunner.RunWithOutput(cmdObj)
|
||||
if err != nil {
|
||||
// if we have an error based on the index lock, we should wait a bit and then retry
|
||||
if strings.Contains(output, ".git/index.lock") {
|
||||
self.log.Error(output)
|
||||
self.log.Info("index.lock prevented command from running. Retrying command after a small wait")
|
||||
attempt++
|
||||
time.Sleep(waitTime)
|
||||
if attempt < retryCount {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return output, err
|
||||
}
|
||||
return self.innerRunner.RunWithOutput(cmdObj)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj oscommands.ICmdObj, onLine func(line string) (bool, error)) error {
|
||||
|
||||
175
pkg/commands/git_commands/bisect.go
Normal file
175
pkg/commands/git_commands/bisect.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BisectCommands struct {
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewBisectCommands(gitCommon *GitCommon) *BisectCommands {
|
||||
return &BisectCommands{
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
// This command is pretty cheap to run so we're not storing the result anywhere.
|
||||
// But if it becomes problematic we can chang that.
|
||||
func (self *BisectCommands) GetInfo() *BisectInfo {
|
||||
var err error
|
||||
info := &BisectInfo{started: false, log: self.Log, newTerm: "bad", oldTerm: "good"}
|
||||
// we return nil if we're not in a git bisect session.
|
||||
// we know we're in a session by the presence of a .git/BISECT_START file
|
||||
|
||||
bisectStartPath := filepath.Join(self.dotGitDir, "BISECT_START")
|
||||
exists, err := self.os.FileExists(bisectStartPath)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return info
|
||||
}
|
||||
|
||||
startContent, err := os.ReadFile(bisectStartPath)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
info.started = true
|
||||
info.start = strings.TrimSpace(string(startContent))
|
||||
|
||||
termsContent, err := os.ReadFile(filepath.Join(self.dotGitDir, "BISECT_TERMS"))
|
||||
if err != nil {
|
||||
// old git versions won't have this file so we default to bad/good
|
||||
} else {
|
||||
splitContent := strings.Split(string(termsContent), "\n")
|
||||
info.newTerm = splitContent[0]
|
||||
info.oldTerm = splitContent[1]
|
||||
}
|
||||
|
||||
bisectRefsDir := filepath.Join(self.dotGitDir, "refs", "bisect")
|
||||
files, err := os.ReadDir(bisectRefsDir)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
info.statusMap = make(map[string]BisectStatus)
|
||||
for _, file := range files {
|
||||
status := BisectStatusSkipped
|
||||
name := file.Name()
|
||||
path := filepath.Join(bisectRefsDir, name)
|
||||
|
||||
fileContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
sha := strings.TrimSpace(string(fileContent))
|
||||
|
||||
if name == info.newTerm {
|
||||
status = BisectStatusNew
|
||||
} else if strings.HasPrefix(name, info.oldTerm+"-") {
|
||||
status = BisectStatusOld
|
||||
} else if strings.HasPrefix(name, "skipped-") {
|
||||
status = BisectStatusSkipped
|
||||
}
|
||||
|
||||
info.statusMap[sha] = status
|
||||
}
|
||||
|
||||
currentContent, err := os.ReadFile(filepath.Join(self.dotGitDir, "BISECT_EXPECTED_REV"))
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
currentSha := strings.TrimSpace(string(currentContent))
|
||||
info.current = currentSha
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Reset() error {
|
||||
return self.cmd.New("git bisect reset").StreamOutput().Run()
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Mark(ref string, term string) error {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git bisect %s %s", term, ref),
|
||||
).
|
||||
IgnoreEmptyError().
|
||||
StreamOutput().
|
||||
Run()
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Skip(ref string) error {
|
||||
return self.Mark(ref, "skip")
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Start() error {
|
||||
return self.cmd.New("git bisect start").StreamOutput().Run()
|
||||
}
|
||||
|
||||
// tells us whether we've found our problem commit(s). We return a string slice of
|
||||
// commit sha's if we're done, and that slice may have more that one item if
|
||||
// skipped commits are involved.
|
||||
func (self *BisectCommands) IsDone() (bool, []string, error) {
|
||||
info := self.GetInfo()
|
||||
if !info.Bisecting() {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
newSha := info.GetNewSha()
|
||||
if newSha == "" {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// if we start from the new commit and reach the a good commit without
|
||||
// coming across any unprocessed commits, then we're done
|
||||
done := false
|
||||
candidates := []string{}
|
||||
|
||||
err := self.cmd.New(fmt.Sprintf("git rev-list %s", newSha)).RunAndProcessLines(func(line string) (bool, error) {
|
||||
sha := strings.TrimSpace(line)
|
||||
|
||||
if status, ok := info.statusMap[sha]; ok {
|
||||
switch status {
|
||||
case BisectStatusSkipped, BisectStatusNew:
|
||||
candidates = append(candidates, sha)
|
||||
return false, nil
|
||||
case BisectStatusOld:
|
||||
done = true
|
||||
return true, nil
|
||||
}
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// should never land here
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return done, candidates, nil
|
||||
}
|
||||
|
||||
// tells us whether the 'start' ref that we'll be sent back to after we're done
|
||||
// bisecting is actually a descendant of our current bisect commit. If it's not, we need to
|
||||
// render the commits from the bad commit.
|
||||
func (self *BisectCommands) ReachableFromStart(bisectInfo *BisectInfo) bool {
|
||||
err := self.cmd.New(
|
||||
fmt.Sprintf("git merge-base --is-ancestor %s %s", bisectInfo.GetNewSha(), bisectInfo.GetStartSha()),
|
||||
).DontLog().Run()
|
||||
|
||||
return err == nil
|
||||
}
|
||||
103
pkg/commands/git_commands/bisect_info.go
Normal file
103
pkg/commands/git_commands/bisect_info.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package git_commands
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
// although the typical terms in a git bisect are 'bad' and 'good', they're more
|
||||
// generally known as 'new' and 'old'. Semi-recently git allowed the user to define
|
||||
// their own terms e.g. when you want to used 'fixed', 'unfixed' in the event
|
||||
// that you're looking for a commit that fixed a bug.
|
||||
|
||||
// Git bisect only keeps track of a single 'bad' commit. Once you pick a commit
|
||||
// that's older than the current bad one, it forgets about the previous one. On
|
||||
// the other hand, it does keep track of all the good and skipped commits.
|
||||
|
||||
type BisectInfo struct {
|
||||
log *logrus.Entry
|
||||
|
||||
// tells us whether all our git bisect files are there meaning we're in bisect mode.
|
||||
// Doesn't necessarily mean that we've actually picked a good/bad commit yet.
|
||||
started bool
|
||||
|
||||
// this is the ref you started the commit from
|
||||
start string // this will always be defined
|
||||
|
||||
// these will be defined if we've started
|
||||
newTerm string // 'bad' by default
|
||||
oldTerm string // 'good' by default
|
||||
|
||||
// map of commit sha's to their status
|
||||
statusMap map[string]BisectStatus
|
||||
|
||||
// the sha of the commit that's under test
|
||||
current string
|
||||
}
|
||||
|
||||
type BisectStatus int
|
||||
|
||||
const (
|
||||
BisectStatusOld BisectStatus = iota
|
||||
BisectStatusNew
|
||||
BisectStatusSkipped
|
||||
)
|
||||
|
||||
// null object pattern
|
||||
func NewNullBisectInfo() *BisectInfo {
|
||||
return &BisectInfo{started: false}
|
||||
}
|
||||
|
||||
func (self *BisectInfo) GetNewSha() string {
|
||||
for sha, status := range self.statusMap {
|
||||
if status == BisectStatusNew {
|
||||
return sha
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *BisectInfo) GetCurrentSha() string {
|
||||
return self.current
|
||||
}
|
||||
|
||||
func (self *BisectInfo) GetStartSha() string {
|
||||
return self.start
|
||||
}
|
||||
|
||||
func (self *BisectInfo) Status(commitSha string) (BisectStatus, bool) {
|
||||
status, ok := self.statusMap[commitSha]
|
||||
return status, ok
|
||||
}
|
||||
|
||||
func (self *BisectInfo) NewTerm() string {
|
||||
return self.newTerm
|
||||
}
|
||||
|
||||
func (self *BisectInfo) OldTerm() string {
|
||||
return self.oldTerm
|
||||
}
|
||||
|
||||
// this is for when we have called `git bisect start`. It does not
|
||||
// mean that we have actually started narrowing things down or selecting good/bad commits
|
||||
func (self *BisectInfo) Started() bool {
|
||||
return self.started
|
||||
}
|
||||
|
||||
// this is where we have both a good and bad revision and we're actually
|
||||
// starting to narrow things down
|
||||
func (self *BisectInfo) Bisecting() bool {
|
||||
if !self.Started() {
|
||||
return false
|
||||
}
|
||||
|
||||
if self.GetNewSha() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, status := range self.statusMap {
|
||||
if status == BisectStatusOld {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -17,18 +16,12 @@ import (
|
||||
const CurrentBranchNameRegex = `(?m)^\*.*?([^ ]*?)\)?$`
|
||||
|
||||
type BranchCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewBranchCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *BranchCommands {
|
||||
func NewBranchCommands(gitCommon *GitCommon) *BranchCommands {
|
||||
return &BranchCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +101,8 @@ func (self *BranchCommands) GetGraphCmdObj(branchName string) oscommands.ICmdObj
|
||||
return self.cmd.New(utils.ResolvePlaceholderString(branchLogCmdTemplate, templateValues)).DontLog()
|
||||
}
|
||||
|
||||
func (self *BranchCommands) SetCurrentBranchUpstream(upstream string) error {
|
||||
return self.cmd.New("git branch --set-upstream-to=" + self.cmd.Quote(upstream)).Run()
|
||||
}
|
||||
|
||||
func (self *BranchCommands) GetUpstream(branchName string) (string, error) {
|
||||
output, err := self.cmd.New(fmt.Sprintf("git rev-parse --abbrev-ref --symbolic-full-name %s@{u}", self.cmd.Quote(branchName))).DontLog().RunWithOutput()
|
||||
return strings.TrimSpace(output), err
|
||||
func (self *BranchCommands) SetCurrentBranchUpstream(remoteName string, remoteBranchName string) error {
|
||||
return self.cmd.New(fmt.Sprintf("git branch --set-upstream-to=%s/%s", self.cmd.Quote(remoteName), self.cmd.Quote(remoteBranchName))).Run()
|
||||
}
|
||||
|
||||
func (self *BranchCommands) SetUpstream(remoteName string, remoteBranchName string, branchName string) error {
|
||||
|
||||
@@ -5,15 +5,9 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func NewBranchCommandsWithRunner(runner *oscommands.FakeCmdObjRunner) *BranchCommands {
|
||||
builder := oscommands.NewDummyCmdObjBuilder(runner)
|
||||
return NewBranchCommands(utils.NewDummyCommon(), builder)
|
||||
}
|
||||
|
||||
func TestBranchGetCommitDifferences(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
@@ -48,7 +42,7 @@ func TestBranchGetCommitDifferences(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := NewBranchCommandsWithRunner(s.runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
pushables, pullables := instance.GetCommitDifferences("HEAD", "@{u}")
|
||||
assert.EqualValues(t, s.expectedPushables, pushables)
|
||||
assert.EqualValues(t, s.expectedPullables, pullables)
|
||||
@@ -60,7 +54,7 @@ func TestBranchGetCommitDifferences(t *testing.T) {
|
||||
func TestBranchNewBranch(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
Expect(`git checkout -b "test" "master"`, "", nil)
|
||||
instance := NewBranchCommandsWithRunner(runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: runner})
|
||||
|
||||
assert.NoError(t, instance.New("test", "master"))
|
||||
runner.CheckForMissingCalls()
|
||||
@@ -96,7 +90,7 @@ func TestBranchDeleteBranch(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := NewBranchCommandsWithRunner(s.runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
|
||||
s.test(instance.Delete("test", s.force))
|
||||
s.runner.CheckForMissingCalls()
|
||||
@@ -107,7 +101,7 @@ func TestBranchDeleteBranch(t *testing.T) {
|
||||
func TestBranchMerge(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
Expect(`git merge --no-edit "test"`, "", nil)
|
||||
instance := NewBranchCommandsWithRunner(runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: runner})
|
||||
|
||||
assert.NoError(t, instance.Merge("test", MergeOpts{}))
|
||||
runner.CheckForMissingCalls()
|
||||
@@ -143,7 +137,7 @@ func TestBranchCheckout(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := NewBranchCommandsWithRunner(s.runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
s.test(instance.Checkout("test", CheckoutOptions{Force: s.force}))
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
@@ -154,7 +148,7 @@ func TestBranchGetBranchGraph(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{
|
||||
"log", "--graph", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium", "test", "--",
|
||||
}, "", nil)
|
||||
instance := NewBranchCommandsWithRunner(runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: runner})
|
||||
_, err := instance.GetGraph("test")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -163,7 +157,7 @@ func TestBranchGetAllBranchGraph(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{
|
||||
"log", "--graph", "--all", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium",
|
||||
}, "", nil)
|
||||
instance := NewBranchCommandsWithRunner(runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: runner})
|
||||
err := instance.AllBranchesLogCmdObj().Run()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -223,7 +217,7 @@ func TestBranchCurrentBranchName(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := NewBranchCommandsWithRunner(s.runner)
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
s.test(instance.CurrentBranchName())
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
|
||||
@@ -5,22 +5,15 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type CommitCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewCommitCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *CommitCommands {
|
||||
func NewCommitCommands(gitCommon *GitCommon) *CommitCommands {
|
||||
return &CommitCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +75,19 @@ func (self *CommitCommands) GetCommitMessage(commitSha string) (string, error) {
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitMessageFirstLine(sha string) (string, error) {
|
||||
return self.cmd.New(fmt.Sprintf("git show --no-patch --pretty=format:%%s %s", sha)).DontLog().RunWithOutput()
|
||||
return self.GetCommitMessagesFirstLine([]string{sha})
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitMessagesFirstLine(shas []string) (string, error) {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git show --no-patch --pretty=format:%%s %s", strings.Join(shas, " ")),
|
||||
).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitsOneline(shas []string) (string, error) {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git show --no-patch --oneline %s", strings.Join(shas, " ")),
|
||||
).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
// AmendHead amends HEAD with whatever is staged in your working tree
|
||||
|
||||
34
pkg/commands/git_commands/common.go
Normal file
34
pkg/commands/git_commands/common.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
gogit "github.com/jesseduffield/go-git/v5"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type GitCommon struct {
|
||||
*common.Common
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
os *oscommands.OSCommand
|
||||
dotGitDir string
|
||||
repo *gogit.Repository
|
||||
config *ConfigCommands
|
||||
}
|
||||
|
||||
func NewGitCommon(
|
||||
cmn *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
osCommand *oscommands.OSCommand,
|
||||
dotGitDir string,
|
||||
repo *gogit.Repository,
|
||||
config *ConfigCommands,
|
||||
) *GitCommon {
|
||||
return &GitCommon{
|
||||
Common: cmn,
|
||||
cmd: cmd,
|
||||
os: osCommand,
|
||||
dotGitDir: dotGitDir,
|
||||
repo: repo,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type CustomCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewCustomCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *CustomCommands {
|
||||
func NewCustomCommands(gitCommon *GitCommon) *CustomCommands {
|
||||
return &CustomCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,120 +22,123 @@ type commonDeps struct {
|
||||
cmd *oscommands.CmdObjBuilder
|
||||
}
|
||||
|
||||
func completeDeps(deps commonDeps) commonDeps {
|
||||
if deps.runner == nil {
|
||||
deps.runner = oscommands.NewFakeRunner(nil)
|
||||
func buildGitCommon(deps commonDeps) *GitCommon {
|
||||
gitCommon := &GitCommon{}
|
||||
|
||||
gitCommon.Common = deps.common
|
||||
if gitCommon.Common == nil {
|
||||
gitCommon.Common = utils.NewDummyCommonWithUserConfig(deps.userConfig)
|
||||
}
|
||||
|
||||
if deps.userConfig == nil {
|
||||
deps.userConfig = config.GetDefaultConfig()
|
||||
runner := deps.runner
|
||||
if runner == nil {
|
||||
runner = oscommands.NewFakeRunner(nil)
|
||||
}
|
||||
|
||||
if deps.gitConfig == nil {
|
||||
deps.gitConfig = git_config.NewFakeGitConfig(nil)
|
||||
cmd := deps.cmd
|
||||
// gotta check deps.cmd because it's not an interface type and an interface value of nil is not considered to be nil
|
||||
if cmd == nil {
|
||||
cmd = oscommands.NewDummyCmdObjBuilder(runner)
|
||||
}
|
||||
gitCommon.cmd = cmd
|
||||
|
||||
gitCommon.Common.UserConfig = deps.userConfig
|
||||
if gitCommon.Common.UserConfig == nil {
|
||||
gitCommon.Common.UserConfig = config.GetDefaultConfig()
|
||||
}
|
||||
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = func(string) string { return "" }
|
||||
gitConfig := deps.gitConfig
|
||||
if gitConfig == nil {
|
||||
gitConfig = git_config.NewFakeGitConfig(nil)
|
||||
}
|
||||
|
||||
if deps.removeFile == nil {
|
||||
deps.removeFile = func(string) error { return errors.New("unexpected call to removeFile") }
|
||||
gitCommon.repo = buildRepo()
|
||||
gitCommon.config = NewConfigCommands(gitCommon.Common, gitConfig, gitCommon.repo)
|
||||
|
||||
getenv := deps.getenv
|
||||
if getenv == nil {
|
||||
getenv = func(string) string { return "" }
|
||||
}
|
||||
|
||||
if deps.dotGitDir == "" {
|
||||
deps.dotGitDir = ".git"
|
||||
removeFile := deps.removeFile
|
||||
if removeFile == nil {
|
||||
removeFile = func(string) error { return errors.New("unexpected call to removeFile") }
|
||||
}
|
||||
|
||||
if deps.common == nil {
|
||||
deps.common = utils.NewDummyCommonWithUserConfig(deps.userConfig)
|
||||
}
|
||||
|
||||
if deps.cmd == nil {
|
||||
deps.cmd = oscommands.NewDummyCmdObjBuilder(deps.runner)
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
func buildConfigCommands(deps commonDeps) *ConfigCommands {
|
||||
deps = completeDeps(deps)
|
||||
common := utils.NewDummyCommonWithUserConfig(deps.userConfig)
|
||||
|
||||
// TODO: think of a way to actually mock this outnil
|
||||
var repo *gogit.Repository = nil
|
||||
|
||||
return NewConfigCommands(common, deps.gitConfig, repo)
|
||||
}
|
||||
|
||||
func buildOSCommand(deps commonDeps) *oscommands.OSCommand {
|
||||
deps = completeDeps(deps)
|
||||
|
||||
return oscommands.NewDummyOSCommandWithDeps(oscommands.OSCommandDeps{
|
||||
Common: deps.common,
|
||||
GetenvFn: deps.getenv,
|
||||
Cmd: deps.cmd,
|
||||
RemoveFileFn: deps.removeFile,
|
||||
gitCommon.os = oscommands.NewDummyOSCommandWithDeps(oscommands.OSCommandDeps{
|
||||
Common: gitCommon.Common,
|
||||
GetenvFn: getenv,
|
||||
Cmd: cmd,
|
||||
RemoveFileFn: removeFile,
|
||||
})
|
||||
|
||||
gitCommon.dotGitDir = deps.dotGitDir
|
||||
if gitCommon.dotGitDir == "" {
|
||||
gitCommon.dotGitDir = ".git"
|
||||
}
|
||||
|
||||
return gitCommon
|
||||
}
|
||||
|
||||
func buildFileLoader(deps commonDeps) *loaders.FileLoader {
|
||||
deps = completeDeps(deps)
|
||||
func buildRepo() *gogit.Repository {
|
||||
// TODO: think of a way to actually mock this out
|
||||
var repo *gogit.Repository = nil
|
||||
return repo
|
||||
}
|
||||
|
||||
configCommands := buildConfigCommands(deps)
|
||||
|
||||
return loaders.NewFileLoader(deps.common, deps.cmd, configCommands)
|
||||
func buildFileLoader(gitCommon *GitCommon) *loaders.FileLoader {
|
||||
return loaders.NewFileLoader(gitCommon.Common, gitCommon.cmd, gitCommon.config)
|
||||
}
|
||||
|
||||
func buildSubmoduleCommands(deps commonDeps) *SubmoduleCommands {
|
||||
deps = completeDeps(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
return NewSubmoduleCommands(deps.common, deps.cmd, deps.dotGitDir)
|
||||
return NewSubmoduleCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildCommitCommands(deps commonDeps) *CommitCommands {
|
||||
deps = completeDeps(deps)
|
||||
return NewCommitCommands(deps.common, deps.cmd)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
return NewCommitCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildWorkingTreeCommands(deps commonDeps) *WorkingTreeCommands {
|
||||
deps = completeDeps(deps)
|
||||
osCommand := buildOSCommand(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
submoduleCommands := buildSubmoduleCommands(deps)
|
||||
fileLoader := buildFileLoader(deps)
|
||||
fileLoader := buildFileLoader(gitCommon)
|
||||
|
||||
return NewWorkingTreeCommands(deps.common, deps.cmd, submoduleCommands, osCommand, fileLoader)
|
||||
return NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader)
|
||||
}
|
||||
|
||||
func buildStashCommands(deps commonDeps) *StashCommands {
|
||||
deps = completeDeps(deps)
|
||||
osCommand := buildOSCommand(deps)
|
||||
fileLoader := buildFileLoader(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
fileLoader := buildFileLoader(gitCommon)
|
||||
workingTreeCommands := buildWorkingTreeCommands(deps)
|
||||
|
||||
return NewStashCommands(deps.common, deps.cmd, osCommand, fileLoader, workingTreeCommands)
|
||||
return NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
|
||||
}
|
||||
|
||||
func buildRebaseCommands(deps commonDeps) *RebaseCommands {
|
||||
deps = completeDeps(deps)
|
||||
configCommands := buildConfigCommands(deps)
|
||||
osCommand := buildOSCommand(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
workingTreeCommands := buildWorkingTreeCommands(deps)
|
||||
commitCommands := buildCommitCommands(deps)
|
||||
|
||||
return NewRebaseCommands(deps.common, deps.cmd, osCommand, commitCommands, workingTreeCommands, configCommands, deps.dotGitDir)
|
||||
return NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
|
||||
}
|
||||
|
||||
func buildSyncCommands(deps commonDeps) *SyncCommands {
|
||||
deps = completeDeps(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
return NewSyncCommands(deps.common, deps.cmd)
|
||||
return NewSyncCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildFileCommands(deps commonDeps) *FileCommands {
|
||||
deps = completeDeps(deps)
|
||||
configCommands := buildConfigCommands(deps)
|
||||
osCommand := buildOSCommand(deps)
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
return NewFileCommands(deps.common, deps.cmd, configCommands, osCommand)
|
||||
return NewFileCommands(gitCommon)
|
||||
}
|
||||
|
||||
func buildBranchCommands(deps commonDeps) *BranchCommands {
|
||||
gitCommon := buildGitCommon(deps)
|
||||
|
||||
return NewBranchCommands(gitCommon)
|
||||
}
|
||||
|
||||
@@ -5,34 +5,16 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type FileCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
config *ConfigCommands
|
||||
os FileOSCommand
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
type FileOSCommand interface {
|
||||
Getenv(string) string
|
||||
}
|
||||
|
||||
func NewFileCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
config *ConfigCommands,
|
||||
osCommand FileOSCommand,
|
||||
) *FileCommands {
|
||||
func NewFileCommands(gitCommon *GitCommon) *FileCommands {
|
||||
return &FileCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
config: config,
|
||||
os: osCommand,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,25 +6,17 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type FlowCommands struct {
|
||||
*common.Common
|
||||
|
||||
config *ConfigCommands
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewFlowCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
config *ConfigCommands,
|
||||
gitCommon *GitCommon,
|
||||
) *FlowCommands {
|
||||
return &FlowCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
config: config,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,40 +5,34 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type PatchCommands struct {
|
||||
*common.Common
|
||||
*GitCommon
|
||||
rebase *RebaseCommands
|
||||
commit *CommitCommands
|
||||
status *StatusCommands
|
||||
stash *StashCommands
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
rebase *RebaseCommands
|
||||
commit *CommitCommands
|
||||
config *ConfigCommands
|
||||
stash *StashCommands
|
||||
status *StatusCommands
|
||||
PatchManager *patch.PatchManager
|
||||
}
|
||||
|
||||
func NewPatchCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
rebaseCommands *RebaseCommands,
|
||||
commitCommands *CommitCommands,
|
||||
configCommands *ConfigCommands,
|
||||
statusCommands *StatusCommands,
|
||||
gitCommon *GitCommon,
|
||||
rebase *RebaseCommands,
|
||||
commit *CommitCommands,
|
||||
status *StatusCommands,
|
||||
stash *StashCommands,
|
||||
patchManager *patch.PatchManager,
|
||||
) *PatchCommands {
|
||||
return &PatchCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
rebase: rebaseCommands,
|
||||
commit: commitCommands,
|
||||
config: configCommands,
|
||||
status: statusCommands,
|
||||
GitCommon: gitCommon,
|
||||
rebase: rebase,
|
||||
commit: commit,
|
||||
status: status,
|
||||
stash: stash,
|
||||
PatchManager: patchManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,39 +9,25 @@ import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type RebaseCommands struct {
|
||||
*common.Common
|
||||
*GitCommon
|
||||
commit *CommitCommands
|
||||
workingTree *WorkingTreeCommands
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
osCommand *oscommands.OSCommand
|
||||
|
||||
commit *CommitCommands
|
||||
workingTree *WorkingTreeCommands
|
||||
config *ConfigCommands
|
||||
dotGitDir string
|
||||
onSuccessfulContinue func() error
|
||||
}
|
||||
|
||||
func NewRebaseCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitCommon *GitCommon,
|
||||
commitCommands *CommitCommands,
|
||||
workingTreeCommands *WorkingTreeCommands,
|
||||
configCommands *ConfigCommands,
|
||||
dotGitDir string,
|
||||
) *RebaseCommands {
|
||||
return &RebaseCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
osCommand: osCommand,
|
||||
GitCommon: gitCommon,
|
||||
commit: commitCommands,
|
||||
workingTree: workingTreeCommands,
|
||||
config: configCommands,
|
||||
dotGitDir: dotGitDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +105,7 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo
|
||||
if todo == "" {
|
||||
gitSequenceEditor = "true"
|
||||
} else {
|
||||
self.osCommand.LogCommand(fmt.Sprintf("Creating TODO file for interactive rebase: \n\n%s", todo), false)
|
||||
self.os.LogCommand(fmt.Sprintf("Creating TODO file for interactive rebase: \n\n%s", todo), false)
|
||||
}
|
||||
|
||||
cmdObj.AddEnvVars(
|
||||
@@ -328,7 +314,7 @@ func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, comm
|
||||
|
||||
// check if file exists in previous commit (this command returns an error if the file doesn't exist)
|
||||
if err := self.cmd.New("git cat-file -e HEAD^:" + self.cmd.Quote(fileName)).Run(); err != nil {
|
||||
if err := self.osCommand.Remove(fileName); err != nil {
|
||||
if err := self.os.Remove(fileName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := self.workingTree.StageFile(fileName); err != nil {
|
||||
|
||||
@@ -2,24 +2,15 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type RemoteCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewRemoteCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *RemoteCommands {
|
||||
func NewRemoteCommands(gitCommon *GitCommon) *RemoteCommands {
|
||||
return &RemoteCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,30 +5,22 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type StashCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
fileLoader *loaders.FileLoader
|
||||
osCommand *oscommands.OSCommand
|
||||
workingTree *WorkingTreeCommands
|
||||
}
|
||||
|
||||
func NewStashCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitCommon *GitCommon,
|
||||
fileLoader *loaders.FileLoader,
|
||||
workingTree *WorkingTreeCommands,
|
||||
) *StashCommands {
|
||||
return &StashCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
fileLoader: fileLoader,
|
||||
osCommand: osCommand,
|
||||
workingTree: workingTree,
|
||||
}
|
||||
}
|
||||
@@ -73,7 +65,7 @@ func (self *StashCommands) SaveStagedChanges(message string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := self.osCommand.PipeCommands("git stash show -p", "git apply -R"); err != nil {
|
||||
if err := self.os.PipeCommands("git stash show -p", "git apply -R"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,43 +4,32 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
gogit "github.com/jesseduffield/go-git/v5"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type StatusCommands struct {
|
||||
*common.Common
|
||||
osCommand *oscommands.OSCommand
|
||||
repo *gogit.Repository
|
||||
dotGitDir string
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewStatusCommands(
|
||||
common *common.Common,
|
||||
osCommand *oscommands.OSCommand,
|
||||
repo *gogit.Repository,
|
||||
dotGitDir string,
|
||||
gitCommon *GitCommon,
|
||||
) *StatusCommands {
|
||||
return &StatusCommands{
|
||||
Common: common,
|
||||
osCommand: osCommand,
|
||||
repo: repo,
|
||||
dotGitDir: dotGitDir,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
// RebaseMode returns "" for non-rebase mode, "normal" for normal rebase
|
||||
// and "interactive" for interactive rebase
|
||||
func (self *StatusCommands) RebaseMode() (enums.RebaseMode, error) {
|
||||
exists, err := self.osCommand.FileExists(filepath.Join(self.dotGitDir, "rebase-apply"))
|
||||
exists, err := self.os.FileExists(filepath.Join(self.dotGitDir, "rebase-apply"))
|
||||
if err != nil {
|
||||
return enums.REBASE_MODE_NONE, err
|
||||
}
|
||||
if exists {
|
||||
return enums.REBASE_MODE_NORMAL, nil
|
||||
}
|
||||
exists, err = self.osCommand.FileExists(filepath.Join(self.dotGitDir, "rebase-merge"))
|
||||
exists, err = self.os.FileExists(filepath.Join(self.dotGitDir, "rebase-merge"))
|
||||
if exists {
|
||||
return enums.REBASE_MODE_INTERACTIVE, err
|
||||
} else {
|
||||
@@ -62,7 +51,7 @@ func (self *StatusCommands) WorkingTreeState() enums.RebaseMode {
|
||||
|
||||
// IsInMergeState states whether we are still mid-merge
|
||||
func (self *StatusCommands) IsInMergeState() (bool, error) {
|
||||
return self.osCommand.FileExists(filepath.Join(self.dotGitDir, "MERGE_HEAD"))
|
||||
return self.os.FileExists(filepath.Join(self.dotGitDir, "MERGE_HEAD"))
|
||||
}
|
||||
|
||||
func (self *StatusCommands) IsBareRepo() bool {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
// .gitmodules looks like this:
|
||||
@@ -19,17 +18,12 @@ import (
|
||||
// url = git@github.com:subbo.git
|
||||
|
||||
type SubmoduleCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
dotGitDir string
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewSubmoduleCommands(common *common.Common, cmd oscommands.ICmdObjBuilder, dotGitDir string) *SubmoduleCommands {
|
||||
func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands {
|
||||
return &SubmoduleCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
dotGitDir: dotGitDir,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +35,7 @@ func (self *SubmoduleCommands) GetConfigs() ([]*models.SubmoduleConfig, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
|
||||
@@ -5,22 +5,15 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type SyncCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewSyncCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *SyncCommands {
|
||||
func NewSyncCommands(gitCommon *GitCommon) *SyncCommands {
|
||||
return &SyncCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,15 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
)
|
||||
|
||||
type TagCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewTagCommands(common *common.Common, cmd oscommands.ICmdObjBuilder) *TagCommands {
|
||||
func NewTagCommands(gitCommon *GitCommon) *TagCommands {
|
||||
return &TagCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,44 +4,30 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type WorkingTreeCommands struct {
|
||||
*common.Common
|
||||
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
os WorkingTreeOSCommand
|
||||
*GitCommon
|
||||
submodule *SubmoduleCommands
|
||||
fileLoader *loaders.FileLoader
|
||||
}
|
||||
|
||||
type WorkingTreeOSCommand interface {
|
||||
RemoveFile(string) error
|
||||
CreateFileWithContent(string, string) error
|
||||
AppendLineToFile(string, string) error
|
||||
}
|
||||
|
||||
func NewWorkingTreeCommands(
|
||||
common *common.Common,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
submoduleCommands *SubmoduleCommands,
|
||||
osCommand WorkingTreeOSCommand,
|
||||
gitCommon *GitCommon,
|
||||
submodule *SubmoduleCommands,
|
||||
fileLoader *loaders.FileLoader,
|
||||
) *WorkingTreeCommands {
|
||||
return &WorkingTreeCommands{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
os: osCommand,
|
||||
submodule: submoduleCommands,
|
||||
GitCommon: gitCommon,
|
||||
submodule: submodule,
|
||||
fileLoader: fileLoader,
|
||||
}
|
||||
}
|
||||
@@ -55,8 +41,16 @@ func (self *WorkingTreeCommands) OpenMergeTool() error {
|
||||
}
|
||||
|
||||
// StageFile stages a file
|
||||
func (self *WorkingTreeCommands) StageFile(fileName string) error {
|
||||
return self.cmd.New("git add -- " + self.cmd.Quote(fileName)).Run()
|
||||
func (self *WorkingTreeCommands) StageFile(path string) error {
|
||||
return self.StageFiles([]string{path})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) StageFiles(paths []string) error {
|
||||
quotedPaths := make([]string, len(paths))
|
||||
for i, path := range paths {
|
||||
quotedPaths[i] = self.cmd.Quote(path)
|
||||
}
|
||||
return self.cmd.New(fmt.Sprintf("git add -- %s", strings.Join(quotedPaths, " "))).Run()
|
||||
}
|
||||
|
||||
// StageAll stages all files
|
||||
@@ -174,12 +168,18 @@ func (self *WorkingTreeCommands) DiscardAllFileChanges(file *models.File) error
|
||||
return self.DiscardUnstagedFileChanges(file)
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) DiscardAllDirChanges(node *filetree.FileNode) error {
|
||||
type IFileNode interface {
|
||||
ForEachFile(cb func(*models.File) error) error
|
||||
GetFilePathsMatching(test func(*models.File) bool) []string
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error {
|
||||
// this could be more efficient but we would need to handle all the edge cases
|
||||
return node.ForEachFile(self.DiscardAllFileChanges)
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node *filetree.FileNode) error {
|
||||
func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error {
|
||||
if err := self.RemoveUntrackedDirFiles(node); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,9 +192,9 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node *filetree.FileNo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node *filetree.FileNode) error {
|
||||
untrackedFilePaths := node.GetPathsMatching(
|
||||
func(n *filetree.FileNode) bool { return n.File != nil && !n.File.GetIsTracked() },
|
||||
func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error {
|
||||
untrackedFilePaths := node.GetFilePathsMatching(
|
||||
func(file *models.File) bool { return !file.GetIsTracked() },
|
||||
)
|
||||
|
||||
for _, path := range untrackedFilePaths {
|
||||
|
||||
@@ -23,6 +23,16 @@ func TestWorkingTreeStageFile(t *testing.T) {
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestWorkingTreeStageFiles(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
Expect(`git add -- "test.txt" "test2.txt"`, "", nil)
|
||||
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: runner})
|
||||
|
||||
assert.NoError(t, instance.StageFiles([]string{"test.txt", "test2.txt"}))
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestWorkingTreeUnstageFile(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -2,6 +2,7 @@ package hosting_service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -42,9 +43,9 @@ func (self *HostingServiceMgr) GetPullRequestURL(from string, to string) (string
|
||||
}
|
||||
|
||||
if to == "" {
|
||||
return gitService.getPullRequestURLIntoDefaultBranch(from), nil
|
||||
return gitService.getPullRequestURLIntoDefaultBranch(url.QueryEscape(from)), nil
|
||||
} else {
|
||||
return gitService.getPullRequestURLIntoTargetBranch(from, to), nil
|
||||
return gitService.getPullRequestURLIntoTargetBranch(url.QueryEscape(from), url.QueryEscape(to)), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +81,7 @@ func (self *HostingServiceMgr) getServiceDomain(repoURL string) (*ServiceDomain,
|
||||
candidateServiceDomains := self.getCandidateServiceDomains()
|
||||
|
||||
for _, serviceDomain := range candidateServiceDomains {
|
||||
// I feel like it makes more sense to see if the repo url contains the service domain's git domain,
|
||||
// but I don't want to break anything by changing that right now.
|
||||
if strings.Contains(repoURL, serviceDomain.serviceDefinition.provider) {
|
||||
if strings.Contains(repoURL, serviceDomain.gitDomain) {
|
||||
return &serviceDomain, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@bitbucket.org:johndoe/social_network.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,7 +92,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/events&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fevents&t=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,7 +101,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@github.com:peter/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://github.com/peter/calculator/compare/feature/sum-operation?expand=1", url)
|
||||
assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Fsum-operation?expand=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -111,7 +111,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@bitbucket.org:johndoe/social_network.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page/avatar&dest=feature/profile-page&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page%2Favatar&dest=feature%2Fprofile-page&t=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -121,7 +121,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/remote-events&dest=feature/events&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fremote-events&dest=feature%2Fevents&t=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -131,7 +131,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@github.com:peter/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://github.com/peter/calculator/compare/feature/operations...feature/sum-operation?expand=1", url)
|
||||
assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Foperations...feature%2Fsum-operation?expand=1", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -140,7 +140,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@gitlab.com:peter/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/ui", url)
|
||||
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature%2Fui", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,7 +149,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@gitlab.com:peter/public/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/ui", url)
|
||||
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature%2Fui", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -159,7 +159,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@gitlab.com:peter/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui", url)
|
||||
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature%2Fcommit-ui&merge_request[target_branch]=epic%2Fui", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,7 +169,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
remoteUrl: "git@gitlab.com:peter/public/calculator.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui", url)
|
||||
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature%2Fcommit-ui&merge_request[target_branch]=epic%2Fui", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -190,7 +190,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
},
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
|
||||
},
|
||||
expectedLoggedErrors: nil,
|
||||
},
|
||||
@@ -203,7 +203,7 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
},
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
|
||||
},
|
||||
expectedLoggedErrors: []string{"Unexpected format for git service: 'noservice.work.com'. Expected something like 'github.com:github.com'"},
|
||||
},
|
||||
@@ -216,10 +216,30 @@ func TestGetPullRequestURL(t *testing.T) {
|
||||
},
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1", url)
|
||||
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
|
||||
},
|
||||
expectedLoggedErrors: []string{"Unknown git service type: 'noservice'. Expected one of github, bitbucket, gitlab"},
|
||||
},
|
||||
{
|
||||
testName: "Escapes reserved URL characters in from branch name",
|
||||
from: "feature/someIssue#123",
|
||||
to: "master",
|
||||
remoteUrl: "git@gitlab.com:me/public/repo-with-issues.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/me/public/repo-with-issues/merge_requests/new?merge_request[source_branch]=feature%2FsomeIssue%23123&merge_request[target_branch]=master", url)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Escapes reserved URL characters in to branch name",
|
||||
from: "yolo",
|
||||
to: "archive/never-ending-feature#666",
|
||||
remoteUrl: "git@gitlab.com:me/public/repo-with-issues.git",
|
||||
test: func(url string, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://gitlab.com/me/public/repo-with-issues/merge_requests/new?merge_request[source_branch]=yolo&merge_request[target_branch]=archive%2Fnever-ending-feature%23666", url)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/go-git/v5/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -20,27 +21,34 @@ import (
|
||||
// if we find out we need to use one of these functions in the git.go file, we
|
||||
// can just pull them out of here and put them there and then call them from in here
|
||||
|
||||
type BranchLoaderConfigCommands interface {
|
||||
Branches() (map[string]*config.Branch, error)
|
||||
}
|
||||
|
||||
// BranchLoader returns a list of Branch objects for the current repo
|
||||
type BranchLoader struct {
|
||||
*common.Common
|
||||
getRawBranches func() (string, error)
|
||||
getCurrentBranchName func() (string, string, error)
|
||||
config BranchLoaderConfigCommands
|
||||
}
|
||||
|
||||
func NewBranchLoader(
|
||||
cmn *common.Common,
|
||||
getRawBranches func() (string, error),
|
||||
getCurrentBranchName func() (string, string, error),
|
||||
config BranchLoaderConfigCommands,
|
||||
) *BranchLoader {
|
||||
return &BranchLoader{
|
||||
Common: cmn,
|
||||
getRawBranches: getRawBranches,
|
||||
getCurrentBranchName: getCurrentBranchName,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Load the list of branches for the current repo
|
||||
func (self *BranchLoader) Load(reflogCommits []*models.Commit) []*models.Branch {
|
||||
func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch, error) {
|
||||
branches := self.obtainBranches()
|
||||
|
||||
reflogBranches := self.obtainReflogBranches(reflogCommits)
|
||||
@@ -77,11 +85,25 @@ outer:
|
||||
if !foundHead {
|
||||
currentBranchName, currentBranchDisplayName, err := self.getCurrentBranchName()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
branches = append([]*models.Branch{{Name: currentBranchName, DisplayName: currentBranchDisplayName, Head: true, Recency: " *"}}, branches...)
|
||||
}
|
||||
return branches
|
||||
|
||||
configBranches, err := self.config.Branches()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, branch := range branches {
|
||||
match := configBranches[branch.Name]
|
||||
if match != nil {
|
||||
branch.UpstreamRemote = match.Remote
|
||||
branch.UpstreamBranch = match.Merge.Short()
|
||||
}
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func (self *BranchLoader) obtainBranches() []*models.Branch {
|
||||
@@ -116,12 +138,13 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
|
||||
|
||||
upstreamName := split[2]
|
||||
if upstreamName == "" {
|
||||
// if we're here then it means we do not have a local version of the remote.
|
||||
// The branch might still be tracking a remote though, we just don't know
|
||||
// how many commits ahead/behind it is
|
||||
branches = append(branches, branch)
|
||||
continue
|
||||
}
|
||||
|
||||
branch.UpstreamName = upstreamName
|
||||
|
||||
track := split[3]
|
||||
re := regexp.MustCompile(`ahead (\d+)`)
|
||||
match := re.FindStringSubmatch(track)
|
||||
|
||||
@@ -41,7 +41,7 @@ d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield||65f910ebd852
|
||||
func TestGetCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
runner oscommands.ICmdObjRunner
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
expectedCommits []*models.Commit
|
||||
expectedError error
|
||||
rebaseMode enums.RebaseMode
|
||||
@@ -208,6 +208,8 @@ func TestGetCommits(t *testing.T) {
|
||||
|
||||
assert.Equal(t, scenario.expectedCommits, commits)
|
||||
assert.Equal(t, scenario.expectedError, err)
|
||||
|
||||
scenario.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
||||
unstagedChange := change[1:2]
|
||||
untracked := utils.IncludesString([]string{"??", "A ", "AM"}, change)
|
||||
hasNoStagedChanges := utils.IncludesString([]string{" ", "U", "?"}, stagedChange)
|
||||
hasMergeConflicts := utils.IncludesString([]string{"DD", "AA", "UU", "AU", "UA", "UD", "DU"}, change)
|
||||
hasInlineMergeConflicts := utils.IncludesString([]string{"UU", "AA"}, change)
|
||||
hasMergeConflicts := hasInlineMergeConflicts || utils.IncludesString([]string{"DD", "AU", "UA", "UD", "DU"}, change)
|
||||
|
||||
file := &models.File{
|
||||
Name: status.Name,
|
||||
|
||||
@@ -32,7 +32,7 @@ func (self *ReflogCommitLoader) GetReflogCommits(lastReflogCommit *models.Commit
|
||||
filterPathArg = fmt.Sprintf(" --follow -- %s", self.cmd.Quote(filterPath))
|
||||
}
|
||||
|
||||
cmdObj := self.cmd.New(fmt.Sprintf(`git log -g --abbrev=20 --format="%%h %%ct %%gs" %s`, filterPathArg)).DontLog()
|
||||
cmdObj := self.cmd.New(fmt.Sprintf(`git log -g --abbrev=20 --format="%%h %%ct %%gs"%s`, filterPathArg)).DontLog()
|
||||
onlyObtainedNewReflogCommits := false
|
||||
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
|
||||
fields := strings.SplitN(line, " ", 3)
|
||||
@@ -49,7 +49,11 @@ func (self *ReflogCommitLoader) GetReflogCommits(lastReflogCommit *models.Commit
|
||||
Status: "reflog",
|
||||
}
|
||||
|
||||
if lastReflogCommit != nil && commit.Sha == lastReflogCommit.Sha && commit.UnixTimestamp == lastReflogCommit.UnixTimestamp {
|
||||
// note that the unix timestamp here is the timestamp of the COMMIT, not the reflog entry itself,
|
||||
// so two consequetive reflog entries may have both the same SHA and therefore same timestamp.
|
||||
// We use the reflog message to disambiguate, and fingers crossed that we never see the same of those
|
||||
// twice in a row. Reason being that it would mean we'd be erroneously exiting early.
|
||||
if lastReflogCommit != nil && commit.Sha == lastReflogCommit.Sha && commit.UnixTimestamp == lastReflogCommit.UnixTimestamp && commit.Name == lastReflogCommit.Name {
|
||||
onlyObtainedNewReflogCommits = true
|
||||
// after this point we already have these reflogs loaded so we'll simply return the new ones
|
||||
return true, nil
|
||||
|
||||
159
pkg/commands/loaders/reflog_commits_test.go
Normal file
159
pkg/commands/loaders/reflog_commits_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/sanity-io/litter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const reflogOutput = `c3c4b66b64c97ffeecde 1643150483 checkout: moving from A to B
|
||||
c3c4b66b64c97ffeecde 1643150483 checkout: moving from B to A
|
||||
c3c4b66b64c97ffeecde 1643150483 checkout: moving from A to B
|
||||
c3c4b66b64c97ffeecde 1643150483 checkout: moving from master to A
|
||||
f4ddf2f0d4be4ccc7efa 1643149435 checkout: moving from A to master
|
||||
`
|
||||
|
||||
func TestGetReflogCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
lastReflogCommit *models.Commit
|
||||
filterPath string
|
||||
expectedCommits []*models.Commit
|
||||
expectedOnlyObtainedNew bool
|
||||
expectedError error
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "no reflog entries",
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, "", nil),
|
||||
|
||||
lastReflogCommit: nil,
|
||||
expectedCommits: []*models.Commit{},
|
||||
expectedOnlyObtainedNew: false,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "some reflog entries",
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, reflogOutput, nil),
|
||||
|
||||
lastReflogCommit: nil,
|
||||
expectedCommits: []*models.Commit{
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from A to B",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from B to A",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from A to B",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from master to A",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
{
|
||||
Sha: "f4ddf2f0d4be4ccc7efa",
|
||||
Name: "checkout: moving from A to master",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643149435,
|
||||
},
|
||||
},
|
||||
expectedOnlyObtainedNew: false,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "some reflog entries where last commit is given",
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, reflogOutput, nil),
|
||||
|
||||
lastReflogCommit: &models.Commit{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from B to A",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
expectedCommits: []*models.Commit{
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from A to B",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
},
|
||||
expectedOnlyObtainedNew: true,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when passing filterPath",
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git log -g --abbrev=20 --format="%h %ct %gs" --follow -- "path"`, reflogOutput, nil),
|
||||
|
||||
lastReflogCommit: &models.Commit{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from B to A",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
filterPath: "path",
|
||||
expectedCommits: []*models.Commit{
|
||||
{
|
||||
Sha: "c3c4b66b64c97ffeecde",
|
||||
Name: "checkout: moving from A to B",
|
||||
Status: "reflog",
|
||||
UnixTimestamp: 1643150483,
|
||||
},
|
||||
},
|
||||
expectedOnlyObtainedNew: true,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when command returns error",
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, "", errors.New("haha")),
|
||||
|
||||
lastReflogCommit: nil,
|
||||
filterPath: "",
|
||||
expectedCommits: nil,
|
||||
expectedOnlyObtainedNew: false,
|
||||
expectedError: errors.New("haha"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario := scenario
|
||||
t.Run(scenario.testName, func(t *testing.T) {
|
||||
builder := &ReflogCommitLoader{
|
||||
Common: utils.NewDummyCommon(),
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
|
||||
}
|
||||
|
||||
commits, onlyObtainednew, err := builder.GetReflogCommits(scenario.lastReflogCommit, scenario.filterPath)
|
||||
assert.Equal(t, scenario.expectedOnlyObtainedNew, onlyObtainednew)
|
||||
assert.Equal(t, scenario.expectedError, err)
|
||||
t.Logf("actual commits: \n%s", litter.Sdump(commits))
|
||||
assert.Equal(t, scenario.expectedCommits, commits)
|
||||
|
||||
scenario.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ package models
|
||||
type Branch struct {
|
||||
Name string
|
||||
// the displayname is something like '(HEAD detached at 123asdf)', whereas in that case the name would be '123asdf'
|
||||
DisplayName string
|
||||
Recency string
|
||||
Pushables string
|
||||
Pullables string
|
||||
UpstreamName string
|
||||
Head bool
|
||||
DisplayName string
|
||||
Recency string
|
||||
Pushables string
|
||||
Pullables string
|
||||
Head bool
|
||||
// if we have a named remote locally this will be the name of that remote e.g.
|
||||
// 'origin' or 'tiwood'. If we don't have the remote locally it'll look like
|
||||
// 'git@github.com:tiwood/lazygit.git'
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
}
|
||||
|
||||
func (b *Branch) RefName() string {
|
||||
@@ -25,22 +29,26 @@ func (b *Branch) Description() string {
|
||||
return b.RefName()
|
||||
}
|
||||
|
||||
// this method does not consider the case where the git config states that a branch is tracking the config.
|
||||
// The Pullables value here is based on whether or not we saw an upstream when doing `git branch`
|
||||
func (b *Branch) IsTrackingRemote() bool {
|
||||
return b.IsRealBranch() && b.Pullables != "?"
|
||||
return b.UpstreamRemote != ""
|
||||
}
|
||||
|
||||
// we know that the remote branch is not stored locally based on our pushable/pullable
|
||||
// count being question marks.
|
||||
func (b *Branch) RemoteBranchStoredLocally() bool {
|
||||
return b.IsTrackingRemote() && b.Pushables != "?" && b.Pullables != "?"
|
||||
}
|
||||
|
||||
func (b *Branch) MatchesUpstream() bool {
|
||||
return b.IsRealBranch() && b.Pushables == "0" && b.Pullables == "0"
|
||||
return b.RemoteBranchStoredLocally() && b.Pushables == "0" && b.Pullables == "0"
|
||||
}
|
||||
|
||||
func (b *Branch) HasCommitsToPush() bool {
|
||||
return b.IsRealBranch() && b.Pushables != "0"
|
||||
return b.RemoteBranchStoredLocally() && b.Pushables != "0"
|
||||
}
|
||||
|
||||
func (b *Branch) HasCommitsToPull() bool {
|
||||
return b.IsRealBranch() && b.Pullables != "0"
|
||||
return b.RemoteBranchStoredLocally() && b.Pullables != "0"
|
||||
}
|
||||
|
||||
// for when we're in a detached head state
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
// Commit : A git commit
|
||||
type Commit struct {
|
||||
@@ -18,10 +22,7 @@ type Commit struct {
|
||||
}
|
||||
|
||||
func (c *Commit) ShortSha() string {
|
||||
if len(c.Sha) < 8 {
|
||||
return c.Sha
|
||||
}
|
||||
return c.Sha[:8]
|
||||
return utils.ShortSha(c.Sha)
|
||||
}
|
||||
|
||||
func (c *Commit) RefName() string {
|
||||
@@ -39,3 +40,9 @@ func (c *Commit) Description() string {
|
||||
func (c *Commit) IsMerge() bool {
|
||||
return len(c.Parents) > 1
|
||||
}
|
||||
|
||||
// returns true if this commit is not actually in the git log but instead
|
||||
// is from a TODO file for an interactive rebase.
|
||||
func (c *Commit) IsTODO() bool {
|
||||
return c.Action != ""
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ type ICmdObj interface {
|
||||
// This returns false if DontLog() was called
|
||||
ShouldLog() bool
|
||||
|
||||
// when you call this, then call Run(), we'll stream the output to the cmdWriter (i.e. the command log panel)
|
||||
StreamOutput() ICmdObj
|
||||
// returns true if StreamOutput() was called
|
||||
ShouldStreamOutput() bool
|
||||
|
||||
// if you call this before ShouldStreamOutput we'll consider an error with no
|
||||
// stderr content as a non-error. Not yet supported for Run or RunWithOutput (
|
||||
// but adding support is trivial)
|
||||
IgnoreEmptyError() ICmdObj
|
||||
// returns true if IgnoreEmptyError() was called
|
||||
ShouldIgnoreEmptyError() bool
|
||||
|
||||
PromptOnCredentialRequest() ICmdObj
|
||||
FailOnCredentialRequest() ICmdObj
|
||||
|
||||
@@ -47,9 +59,15 @@ type CmdObj struct {
|
||||
|
||||
runner ICmdObjRunner
|
||||
|
||||
// if set to true, we don't want to log the command to the user.
|
||||
// see DontLog()
|
||||
dontLog bool
|
||||
|
||||
// see StreamOutput()
|
||||
streamOutput bool
|
||||
|
||||
// see IgnoreEmptyError()
|
||||
ignoreEmptyError bool
|
||||
|
||||
// if set to true, it means we might be asked to enter a username/password by this command.
|
||||
credentialStrategy CredentialStrategy
|
||||
}
|
||||
@@ -98,6 +116,26 @@ func (self *CmdObj) ShouldLog() bool {
|
||||
return !self.dontLog
|
||||
}
|
||||
|
||||
func (self *CmdObj) StreamOutput() ICmdObj {
|
||||
self.streamOutput = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CmdObj) ShouldStreamOutput() bool {
|
||||
return self.streamOutput
|
||||
}
|
||||
|
||||
func (self *CmdObj) IgnoreEmptyError() ICmdObj {
|
||||
self.ignoreEmptyError = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CmdObj) ShouldIgnoreEmptyError() bool {
|
||||
return self.ignoreEmptyError
|
||||
}
|
||||
|
||||
func (self *CmdObj) Run() error {
|
||||
return self.runner.Run(self)
|
||||
}
|
||||
|
||||
@@ -34,15 +34,27 @@ type cmdObjRunner struct {
|
||||
var _ ICmdObjRunner = &cmdObjRunner{}
|
||||
|
||||
func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
|
||||
if cmdObj.GetCredentialStrategy() == NONE {
|
||||
_, err := self.RunWithOutput(cmdObj)
|
||||
return err
|
||||
} else {
|
||||
if cmdObj.GetCredentialStrategy() != NONE {
|
||||
return self.runWithCredentialHandling(cmdObj)
|
||||
}
|
||||
|
||||
if cmdObj.ShouldStreamOutput() {
|
||||
return self.runAndStream(cmdObj)
|
||||
}
|
||||
|
||||
_, err := self.RunWithOutput(cmdObj)
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
|
||||
if cmdObj.ShouldStreamOutput() {
|
||||
err := self.runAndStream(cmdObj)
|
||||
// for now we're not capturing output, just because it would take a little more
|
||||
// effort and there's currently no use case for it. Some commands call RunWithOutput
|
||||
// but ignore the output, hence why we've got this check here.
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cmdObj.GetCredentialStrategy() != NONE {
|
||||
err := self.runWithCredentialHandling(cmdObj)
|
||||
// for now we're not capturing output, just because it would take a little more
|
||||
@@ -51,6 +63,8 @@ func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
|
||||
|
||||
if cmdObj.ShouldLog() {
|
||||
self.logCmdObj(cmdObj)
|
||||
}
|
||||
@@ -143,12 +157,36 @@ type cmdHandler struct {
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error {
|
||||
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// runAndDetectCredentialRequest detect a username / password / passphrase question in a command
|
||||
// promptUserForCredential is a function that gets executed when this function detect you need to fillin a password or passphrase
|
||||
// The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back
|
||||
func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
cmdObj ICmdObj,
|
||||
promptUserForCredential func(CredentialType) string,
|
||||
) error {
|
||||
// setting the output to english so we can parse it for a username/password request
|
||||
cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8")
|
||||
|
||||
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
||||
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
|
||||
|
||||
go utils.Safe(func() {
|
||||
self.processOutput(tr, handler.stdinPipe, promptUserForCredential)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) runAndStreamAux(
|
||||
cmdObj ICmdObj,
|
||||
onRun func(*cmdHandler, io.Writer),
|
||||
) error {
|
||||
cmdWriter := self.guiIO.newCmdWriterFn()
|
||||
|
||||
@@ -156,7 +194,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
self.logCmdObj(cmdObj)
|
||||
}
|
||||
self.log.WithField("command", cmdObj.ToString()).Info("RunCommand")
|
||||
cmd := cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8").GetCmd()
|
||||
cmd := cmdObj.GetCmd()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = io.MultiWriter(cmdWriter, &stderr)
|
||||
@@ -172,14 +210,14 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
}
|
||||
}()
|
||||
|
||||
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
|
||||
|
||||
go utils.Safe(func() {
|
||||
self.processOutput(tr, handler.stdinPipe, promptUserForCredential)
|
||||
})
|
||||
onRun(handler, cmdWriter)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
errStr := stderr.String()
|
||||
if cmdObj.ShouldIgnoreEmptyError() && errStr == "" {
|
||||
return nil
|
||||
}
|
||||
return errors.New(stderr.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -267,3 +267,15 @@ func GetLazygitPath() string {
|
||||
}
|
||||
return `"` + filepath.ToSlash(ex) + `"`
|
||||
}
|
||||
|
||||
func (c *OSCommand) UpdateWindowTitle() error {
|
||||
if c.Platform.OS != "windows" {
|
||||
return nil
|
||||
}
|
||||
path, getWdErr := os.Getwd()
|
||||
if getWdErr != nil {
|
||||
return getWdErr
|
||||
}
|
||||
argString := fmt.Sprint("title ", filepath.Base(path), " - Lazygit")
|
||||
return c.Cmd.NewShell(argString).Run()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type RefresherConfig struct {
|
||||
|
||||
type GuiConfig struct {
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
BranchColors map[string]string `yaml:"branchColors"`
|
||||
ScrollHeight int `yaml:"scrollHeight"`
|
||||
ScrollPastBottom bool `yaml:"scrollPastBottom"`
|
||||
MouseEvents bool `yaml:"mouseEvents"`
|
||||
@@ -247,6 +248,7 @@ type KeybindingCommitsConfig struct {
|
||||
CopyCommitMessageToClipboard string `yaml:"copyCommitMessageToClipboard"`
|
||||
OpenLogMenu string `yaml:"openLogMenu"`
|
||||
OpenInBrowser string `yaml:"openInBrowser"`
|
||||
ViewBisectOptions string `yaml:"viewBisectOptions"`
|
||||
}
|
||||
|
||||
type KeybindingStashConfig struct {
|
||||
@@ -293,6 +295,7 @@ type CustomCommand struct {
|
||||
Prompts []CustomCommandPrompt `yaml:"prompts"`
|
||||
LoadingText string `yaml:"loadingText"`
|
||||
Description string `yaml:"description"`
|
||||
Stream bool `yaml:"stream"`
|
||||
}
|
||||
|
||||
type CustomCommandPrompt struct {
|
||||
@@ -325,7 +328,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
ScrollPastBottom: true,
|
||||
MouseEvents: true,
|
||||
SkipUnstageLineWarning: false,
|
||||
SkipStashWarning: true,
|
||||
SkipStashWarning: false,
|
||||
SidePanelWidth: 0.3333,
|
||||
ExpandFocusedSidePanel: false,
|
||||
MainPanelSplitMode: "flexible",
|
||||
@@ -510,6 +513,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
CopyCommitMessageToClipboard: "<c-y>",
|
||||
OpenLogMenu: "<c-l>",
|
||||
OpenInBrowser: "o",
|
||||
ViewBisectOptions: "b",
|
||||
},
|
||||
Stash: KeybindingStashConfig{
|
||||
PopStash: "g",
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -96,11 +95,13 @@ func (gui *Gui) renderAppStatus() {
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
appStatus := gui.statusManager.getStatusString()
|
||||
gui.OnUIThread(func() error {
|
||||
return gui.renderString(gui.Views.AppStatus, appStatus)
|
||||
})
|
||||
|
||||
if appStatus == "" {
|
||||
gui.renderString(gui.Views.AppStatus, "")
|
||||
return
|
||||
}
|
||||
gui.renderString(gui.Views.AppStatus, appStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -117,7 +118,7 @@ func (gui *Gui) WithWaitingStatus(message string, f func() error) error {
|
||||
gui.renderAppStatus()
|
||||
|
||||
if err := f(); err != nil {
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
gui.OnUIThread(func() error {
|
||||
return gui.surfaceError(err)
|
||||
})
|
||||
}
|
||||
|
||||
219
pkg/gui/bisect.go
Normal file
219
pkg/gui/bisect.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
)
|
||||
|
||||
func (gui *Gui) handleOpenBisectMenu() error {
|
||||
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// no shame in getting this directly rather than using the cached value
|
||||
// given how cheap it is to obtain
|
||||
info := gui.Git.Bisect.GetInfo()
|
||||
commit := gui.getSelectedLocalCommit()
|
||||
if info.Started() {
|
||||
return gui.openMidBisectMenu(info, commit)
|
||||
} else {
|
||||
return gui.openStartBisectMenu(info, commit)
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
|
||||
// if there is not yet a 'current' bisect commit, or if we have
|
||||
// selected the current commit, we need to jump to the next 'current' commit
|
||||
// after we perform a bisect action. The reason we don't unconditionally jump
|
||||
// is that sometimes the user will want to go and mark a few commits as skipped
|
||||
// in a row and they wouldn't want to be jumped back to the current bisect
|
||||
// commit each time.
|
||||
// Originally we were allowing the user to, from the bisect menu, select whether
|
||||
// they were talking about the selected commit or the current bisect commit,
|
||||
// and that was a bit confusing (and required extra keypresses).
|
||||
selectCurrentAfter := info.GetCurrentSha() == "" || info.GetCurrentSha() == commit.Sha
|
||||
// we need to wait to reselect if our bisect commits aren't ancestors of our 'start'
|
||||
// ref, because we'll be reloading our commits in that case.
|
||||
waitToReselect := selectCurrentAfter && !gui.Git.Bisect.ReachableFromStart(info)
|
||||
|
||||
menuItems := []*menuItem{
|
||||
{
|
||||
displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.NewTerm()),
|
||||
onPress: func() error {
|
||||
gui.logAction(gui.Tr.Actions.BisectMark)
|
||||
if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.afterMark(selectCurrentAfter, waitToReselect)
|
||||
},
|
||||
},
|
||||
{
|
||||
displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.OldTerm()),
|
||||
onPress: func() error {
|
||||
gui.logAction(gui.Tr.Actions.BisectMark)
|
||||
if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.afterMark(selectCurrentAfter, waitToReselect)
|
||||
},
|
||||
},
|
||||
{
|
||||
displayString: fmt.Sprintf(gui.Tr.Bisect.Skip, commit.ShortSha()),
|
||||
onPress: func() error {
|
||||
gui.logAction(gui.Tr.Actions.BisectSkip)
|
||||
if err := gui.Git.Bisect.Skip(commit.Sha); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.afterMark(selectCurrentAfter, waitToReselect)
|
||||
},
|
||||
},
|
||||
{
|
||||
displayString: gui.Tr.Bisect.ResetOption,
|
||||
onPress: func() error {
|
||||
return gui.resetBisect()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return gui.createMenu(
|
||||
gui.Tr.Bisect.BisectMenuTitle,
|
||||
menuItems,
|
||||
createMenuOptions{showCancel: true},
|
||||
)
|
||||
}
|
||||
|
||||
func (gui *Gui) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
|
||||
return gui.createMenu(
|
||||
gui.Tr.Bisect.BisectMenuTitle,
|
||||
[]*menuItem{
|
||||
{
|
||||
displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.NewTerm()),
|
||||
onPress: func() error {
|
||||
gui.logAction(gui.Tr.Actions.StartBisect)
|
||||
if err := gui.Git.Bisect.Start(); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.postBisectCommandRefresh()
|
||||
},
|
||||
},
|
||||
{
|
||||
displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
|
||||
onPress: func() error {
|
||||
gui.logAction(gui.Tr.Actions.StartBisect)
|
||||
if err := gui.Git.Bisect.Start(); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.postBisectCommandRefresh()
|
||||
},
|
||||
},
|
||||
},
|
||||
createMenuOptions{showCancel: true},
|
||||
)
|
||||
}
|
||||
|
||||
func (gui *Gui) resetBisect() error {
|
||||
return gui.ask(askOpts{
|
||||
title: gui.Tr.Bisect.ResetTitle,
|
||||
prompt: gui.Tr.Bisect.ResetPrompt,
|
||||
handleConfirm: func() error {
|
||||
gui.logAction(gui.Tr.Actions.ResetBisect)
|
||||
if err := gui.Git.Bisect.Reset(); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.postBisectCommandRefresh()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) showBisectCompleteMessage(candidateShas []string) error {
|
||||
prompt := gui.Tr.Bisect.CompletePrompt
|
||||
if len(candidateShas) > 1 {
|
||||
prompt = gui.Tr.Bisect.CompletePromptIndeterminate
|
||||
}
|
||||
|
||||
formattedCommits, err := gui.Git.Commit.GetCommitsOneline(candidateShas)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.ask(askOpts{
|
||||
title: gui.Tr.Bisect.CompleteTitle,
|
||||
prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)),
|
||||
handleConfirm: func() error {
|
||||
gui.logAction(gui.Tr.Actions.ResetBisect)
|
||||
if err := gui.Git.Bisect.Reset(); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
return gui.postBisectCommandRefresh()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) afterMark(selectCurrent bool, waitToReselect bool) error {
|
||||
done, candidateShas, err := gui.Git.Bisect.IsDone()
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if err := gui.afterBisectMarkRefresh(selectCurrent, waitToReselect); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if done {
|
||||
return gui.showBisectCompleteMessage(candidateShas)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) postBisectCommandRefresh() error {
|
||||
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{}})
|
||||
}
|
||||
|
||||
func (gui *Gui) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
|
||||
selectFn := func() {
|
||||
if selectCurrent {
|
||||
gui.selectCurrentBisectCommit()
|
||||
}
|
||||
}
|
||||
|
||||
if waitToReselect {
|
||||
return gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{}, then: selectFn})
|
||||
} else {
|
||||
selectFn()
|
||||
|
||||
return gui.postBisectCommandRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) selectCurrentBisectCommit() {
|
||||
info := gui.Git.Bisect.GetInfo()
|
||||
if info.GetCurrentSha() != "" {
|
||||
// find index of commit with that sha, move cursor to that.
|
||||
for i, commit := range gui.State.Commits {
|
||||
if commit.Sha == info.GetCurrentSha() {
|
||||
gui.State.Contexts.BranchCommits.GetPanelState().SetSelectedLineIdx(i)
|
||||
_ = gui.State.Contexts.BranchCommits.HandleFocus()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,12 @@ func (gui *Gui) refreshBranches() {
|
||||
}
|
||||
}
|
||||
|
||||
gui.State.Branches = gui.Git.Loaders.Branches.Load(reflogCommits)
|
||||
branches, err := gui.Git.Loaders.Branches.Load(reflogCommits)
|
||||
if err != nil {
|
||||
_ = gui.surfaceError(err)
|
||||
}
|
||||
|
||||
gui.State.Branches = branches
|
||||
|
||||
if err := gui.postRefreshUpdate(gui.State.Contexts.Branches); err != nil {
|
||||
gui.Log.Error(err)
|
||||
@@ -392,25 +397,19 @@ func (gui *Gui) handleFastForward() error {
|
||||
if !branch.IsTrackingRemote() {
|
||||
return gui.createErrorPanel(gui.Tr.FwdNoUpstream)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() {
|
||||
return gui.createErrorPanel(gui.Tr.FwdNoLocalUpstream)
|
||||
}
|
||||
if branch.HasCommitsToPush() {
|
||||
return gui.createErrorPanel(gui.Tr.FwdCommitsToPush)
|
||||
}
|
||||
|
||||
upstream, err := gui.Git.Branch.GetUpstream(branch.Name)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
action := gui.Tr.Actions.FastForwardBranch
|
||||
|
||||
split := strings.Split(upstream, "/")
|
||||
remoteName := split[0]
|
||||
remoteBranchName := strings.Join(split[1:], "/")
|
||||
|
||||
message := utils.ResolvePlaceholderString(
|
||||
gui.Tr.Fetching,
|
||||
map[string]string{
|
||||
"from": fmt.Sprintf("%s/%s", remoteName, remoteBranchName),
|
||||
"from": fmt.Sprintf("%s/%s", branch.UpstreamRemote, branch.UpstreamBranch),
|
||||
"to": branch.Name,
|
||||
},
|
||||
)
|
||||
@@ -421,7 +420,7 @@ func (gui *Gui) handleFastForward() error {
|
||||
_ = gui.pullWithLock(PullFilesOptions{action: action, FastForwardOnly: true})
|
||||
} else {
|
||||
gui.logAction(action)
|
||||
err := gui.Git.Sync.FastForward(branch.Name, remoteName, remoteBranchName)
|
||||
err := gui.Git.Sync.FastForward(branch.Name, branch.UpstreamRemote, branch.UpstreamBranch)
|
||||
gui.handleCredentialsPopup(err)
|
||||
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{BRANCHES}})
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
|
||||
func (gui *Gui) getSelectedCommitFileNode() *filetree.CommitFileNode {
|
||||
selectedLine := gui.State.Panels.CommitFiles.SelectedLineIdx
|
||||
if selectedLine == -1 || selectedLine > gui.State.CommitFileManager.GetItemsLength()-1 {
|
||||
if selectedLine == -1 || selectedLine > gui.State.CommitFileTreeViewModel.GetItemsLength()-1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.State.CommitFileManager.GetItemAtIndex(selectedLine)
|
||||
return gui.State.CommitFileTreeViewModel.GetItemAtIndex(selectedLine)
|
||||
}
|
||||
|
||||
func (gui *Gui) getSelectedCommitFile() *models.CommitFile {
|
||||
@@ -42,7 +42,7 @@ func (gui *Gui) commitFilesRenderToMain() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
to := gui.State.CommitFileManager.GetParent()
|
||||
to := gui.State.CommitFileTreeViewModel.GetParent()
|
||||
from, reverse := gui.getFromAndReverseArgsForDiff(to)
|
||||
|
||||
cmdObj := gui.Git.WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
|
||||
@@ -64,7 +64,7 @@ func (gui *Gui) handleCheckoutCommitFile() error {
|
||||
}
|
||||
|
||||
gui.logAction(gui.Tr.Actions.CheckoutFile)
|
||||
if err := gui.Git.WorkingTree.CheckoutFile(gui.State.CommitFileManager.GetParent(), node.GetPath()); err != nil {
|
||||
if err := gui.Git.WorkingTree.CheckoutFile(gui.State.CommitFileTreeViewModel.GetParent(), node.GetPath()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ func (gui *Gui) refreshCommitFilesView() error {
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
gui.State.CommitFileManager.SetFiles(files, to)
|
||||
gui.State.CommitFileTreeViewModel.SetParent(to)
|
||||
gui.State.CommitFileTreeViewModel.SetFiles(files)
|
||||
|
||||
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
|
||||
}
|
||||
@@ -154,7 +155,7 @@ func (gui *Gui) handleToggleFileForPatch() error {
|
||||
// if there is any file that hasn't been fully added we'll fully add everything,
|
||||
// otherwise we'll remove everything
|
||||
adding := node.AnyFile(func(file *models.CommitFile) bool {
|
||||
return gui.Git.Patch.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileManager.GetParent()) != patch.WHOLE
|
||||
return gui.Git.Patch.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileTreeViewModel.GetParent()) != patch.WHOLE
|
||||
})
|
||||
|
||||
err := node.ForEachFile(func(file *models.CommitFile) error {
|
||||
@@ -176,7 +177,7 @@ func (gui *Gui) handleToggleFileForPatch() error {
|
||||
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
|
||||
}
|
||||
|
||||
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileManager.GetParent() {
|
||||
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
|
||||
return gui.ask(askOpts{
|
||||
title: gui.Tr.DiscardPatch,
|
||||
prompt: gui.Tr.DiscardPatchConfirm,
|
||||
@@ -224,18 +225,14 @@ func (gui *Gui) enterCommitFile(opts OnFocusOpts) error {
|
||||
return gui.pushContext(gui.State.Contexts.PatchBuilding, opts)
|
||||
}
|
||||
|
||||
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileManager.GetParent() {
|
||||
if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
|
||||
return gui.ask(askOpts{
|
||||
title: gui.Tr.DiscardPatch,
|
||||
prompt: gui.Tr.DiscardPatchConfirm,
|
||||
handlersManageFocus: true,
|
||||
title: gui.Tr.DiscardPatch,
|
||||
prompt: gui.Tr.DiscardPatchConfirm,
|
||||
handleConfirm: func() error {
|
||||
gui.Git.Patch.PatchManager.Reset()
|
||||
return enterTheFile()
|
||||
},
|
||||
handleClose: func() error {
|
||||
return gui.pushContext(gui.State.Contexts.CommitFiles)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -248,7 +245,7 @@ func (gui *Gui) handleToggleCommitFileDirCollapsed() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
gui.State.CommitFileManager.ToggleCollapsed(node.GetPath())
|
||||
gui.State.CommitFileTreeViewModel.ToggleCollapsed(node.GetPath())
|
||||
|
||||
if err := gui.postRefreshUpdate(gui.State.Contexts.CommitFiles); err != nil {
|
||||
gui.Log.Error(err)
|
||||
@@ -279,12 +276,12 @@ func (gui *Gui) switchToCommitFilesContext(refName string, canRebase bool, conte
|
||||
func (gui *Gui) handleToggleCommitFileTreeView() error {
|
||||
path := gui.getSelectedCommitFilePath()
|
||||
|
||||
gui.State.CommitFileManager.ToggleShowTree()
|
||||
gui.State.CommitFileTreeViewModel.ToggleShowTree()
|
||||
|
||||
// find that same node in the new format and move the cursor to it
|
||||
if path != "" {
|
||||
gui.State.CommitFileManager.ExpandToPath(path)
|
||||
index, found := gui.State.CommitFileManager.GetIndexForPath(path)
|
||||
gui.State.CommitFileTreeViewModel.ExpandToPath(path)
|
||||
index, found := gui.State.CommitFileTreeViewModel.GetIndexForPath(path)
|
||||
if found {
|
||||
gui.State.Contexts.CommitFiles.GetPanelState().SetSelectedLineIdx(index)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,7 @@ func (gui *Gui) handleCommitMessageFocused() error {
|
||||
},
|
||||
)
|
||||
|
||||
gui.renderString(gui.Views.Options, message)
|
||||
return nil
|
||||
return gui.renderString(gui.Views.Options, message)
|
||||
}
|
||||
|
||||
func (gui *Gui) getBufferLength(view *gocui.View) string {
|
||||
|
||||
@@ -121,7 +121,7 @@ func (gui *Gui) refreshCommitsWithLimit() error {
|
||||
Limit: gui.State.Panels.Commits.LimitCommits,
|
||||
FilterPath: gui.State.Modes.Filtering.GetPath(),
|
||||
IncludeRebaseCommits: true,
|
||||
RefName: "HEAD",
|
||||
RefName: gui.refForLog(),
|
||||
All: gui.State.ShowWholeGitGraph,
|
||||
},
|
||||
)
|
||||
@@ -133,6 +133,22 @@ func (gui *Gui) refreshCommitsWithLimit() error {
|
||||
return gui.postRefreshUpdate(gui.State.Contexts.BranchCommits)
|
||||
}
|
||||
|
||||
func (gui *Gui) refForLog() string {
|
||||
bisectInfo := gui.Git.Bisect.GetInfo()
|
||||
gui.State.BisectInfo = bisectInfo
|
||||
|
||||
if !bisectInfo.Started() {
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
// need to see if our bisect's current commit is reachable from our 'new' ref.
|
||||
if bisectInfo.Bisecting() && !gui.Git.Bisect.ReachableFromStart(bisectInfo) {
|
||||
return bisectInfo.GetNewSha()
|
||||
}
|
||||
|
||||
return bisectInfo.GetStartSha()
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshRebaseCommits() error {
|
||||
gui.Mutexes.BranchCommitsMutex.Lock()
|
||||
defer gui.Mutexes.BranchCommitsMutex.Unlock()
|
||||
@@ -458,17 +474,25 @@ func (gui *Gui) handleCommitRevert() error {
|
||||
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
commit := gui.getSelectedLocalCommit()
|
||||
|
||||
if commit.IsMerge() {
|
||||
return gui.createRevertMergeCommitMenu(commit)
|
||||
} else {
|
||||
gui.logAction(gui.Tr.Actions.RevertCommit)
|
||||
if err := gui.Git.Commit.Revert(commit.Sha); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
return gui.afterRevertCommit()
|
||||
return gui.ask(askOpts{
|
||||
title: gui.Tr.Actions.RevertCommit,
|
||||
prompt: utils.ResolvePlaceholderString(
|
||||
gui.Tr.ConfirmRevertCommit,
|
||||
map[string]string{
|
||||
"selectedCommit": commit.ShortSha(),
|
||||
}),
|
||||
handleConfirm: func() error {
|
||||
gui.logAction(gui.Tr.Actions.RevertCommit)
|
||||
if err := gui.Git.Commit.Revert(commit.Sha); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
return gui.afterRevertCommit()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ type askOpts struct {
|
||||
type promptOpts struct {
|
||||
title string
|
||||
initialContent string
|
||||
handleConfirm func(string) error
|
||||
findSuggestionsFunc func(string) []*types.Suggestion
|
||||
handleConfirm func(string) error
|
||||
}
|
||||
|
||||
func (gui *Gui) ask(opts askOpts) error {
|
||||
@@ -54,32 +54,32 @@ func (gui *Gui) createLoaderPanel(prompt string) error {
|
||||
|
||||
func (gui *Gui) wrappedConfirmationFunction(handlersManageFocus bool, function func() error) func() error {
|
||||
return func() error {
|
||||
if function != nil {
|
||||
if err := function(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := gui.closeConfirmationPrompt(handlersManageFocus); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if function != nil {
|
||||
if err := function(); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) wrappedPromptConfirmationFunction(handlersManageFocus bool, function func(string) error, getResponse func() string) func() error {
|
||||
return func() error {
|
||||
if err := gui.closeConfirmationPrompt(handlersManageFocus); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if function != nil {
|
||||
if err := function(getResponse()); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := gui.closeConfirmationPrompt(handlersManageFocus); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -176,45 +176,43 @@ func (gui *Gui) prepareConfirmationPanel(
|
||||
suggestionsView.Title = fmt.Sprintf(gui.Tr.SuggestionsTitle, gui.UserConfig.Keybinding.Universal.TogglePanel)
|
||||
}
|
||||
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
return gui.pushContext(gui.State.Contexts.Confirmation)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) createPopupPanel(opts createPopupPanelOpts) error {
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
// remove any previous keybindings
|
||||
gui.clearConfirmationViewKeyBindings()
|
||||
// remove any previous keybindings
|
||||
gui.clearConfirmationViewKeyBindings()
|
||||
|
||||
err := gui.prepareConfirmationPanel(
|
||||
opts.title,
|
||||
opts.prompt,
|
||||
opts.hasLoader,
|
||||
opts.findSuggestionsFunc,
|
||||
opts.editable,
|
||||
)
|
||||
if err != nil {
|
||||
err := gui.prepareConfirmationPanel(
|
||||
opts.title,
|
||||
opts.prompt,
|
||||
opts.hasLoader,
|
||||
opts.findSuggestionsFunc,
|
||||
opts.editable,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
confirmationView := gui.Views.Confirmation
|
||||
confirmationView.Editable = opts.editable
|
||||
confirmationView.Editor = gocui.EditorFunc(gui.defaultEditor)
|
||||
|
||||
if opts.editable {
|
||||
textArea := confirmationView.TextArea
|
||||
textArea.Clear()
|
||||
textArea.TypeString(opts.prompt)
|
||||
confirmationView.RenderTextArea()
|
||||
} else {
|
||||
if err := gui.renderString(confirmationView, opts.prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
confirmationView := gui.Views.Confirmation
|
||||
confirmationView.Editable = opts.editable
|
||||
confirmationView.Editor = gocui.EditorFunc(gui.defaultEditor)
|
||||
}
|
||||
|
||||
if opts.editable {
|
||||
textArea := confirmationView.TextArea
|
||||
textArea.Clear()
|
||||
textArea.TypeString(opts.prompt)
|
||||
confirmationView.RenderTextArea()
|
||||
} else {
|
||||
if err := gui.renderStringSync(confirmationView, opts.prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := gui.setKeyBindings(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.setKeyBindings(opts)
|
||||
})
|
||||
return nil
|
||||
return gui.pushContext(gui.State.Contexts.Confirmation)
|
||||
}
|
||||
|
||||
func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
|
||||
@@ -226,7 +224,7 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
|
||||
},
|
||||
)
|
||||
|
||||
gui.renderString(gui.Views.Options, actions)
|
||||
_ = gui.renderString(gui.Views.Options, actions)
|
||||
var onConfirm func() error
|
||||
if opts.handleConfirmPrompt != nil {
|
||||
onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() })
|
||||
@@ -329,5 +327,9 @@ func (gui *Gui) surfaceError(err error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == gocui.ErrQuit {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
|
||||
@@ -71,21 +71,17 @@ func (gui *Gui) currentContextKeyIgnoringPopups() ContextKey {
|
||||
// use replaceContext when you don't want to return to the original context upon
|
||||
// hitting escape: you want to go that context's parent instead.
|
||||
func (gui *Gui) replaceContext(c Context) error {
|
||||
gui.g.Update(func(*gocui.Gui) error {
|
||||
gui.State.ContextManager.Lock()
|
||||
defer gui.State.ContextManager.Unlock()
|
||||
gui.State.ContextManager.Lock()
|
||||
defer gui.State.ContextManager.Unlock()
|
||||
|
||||
if len(gui.State.ContextManager.ContextStack) == 0 {
|
||||
gui.State.ContextManager.ContextStack = []Context{c}
|
||||
} else {
|
||||
// replace the last item with the given item
|
||||
gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack[0:len(gui.State.ContextManager.ContextStack)-1], c)
|
||||
}
|
||||
if len(gui.State.ContextManager.ContextStack) == 0 {
|
||||
gui.State.ContextManager.ContextStack = []Context{c}
|
||||
} else {
|
||||
// replace the last item with the given item
|
||||
gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack[0:len(gui.State.ContextManager.ContextStack)-1], c)
|
||||
}
|
||||
|
||||
return gui.activateContext(c)
|
||||
})
|
||||
|
||||
return nil
|
||||
return gui.activateContext(c)
|
||||
}
|
||||
|
||||
func (gui *Gui) pushContext(c Context, opts ...OnFocusOpts) error {
|
||||
@@ -94,14 +90,6 @@ func (gui *Gui) pushContext(c Context, opts ...OnFocusOpts) error {
|
||||
return errors.New("cannot pass multiple opts to pushContext")
|
||||
}
|
||||
|
||||
gui.g.Update(func(*gocui.Gui) error {
|
||||
return gui.pushContextDirect(c, opts...)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) pushContextDirect(c Context, opts ...OnFocusOpts) error {
|
||||
gui.State.ContextManager.Lock()
|
||||
|
||||
// push onto stack
|
||||
@@ -139,14 +127,6 @@ func (gui *Gui) pushContextWithView(viewName string) error {
|
||||
}
|
||||
|
||||
func (gui *Gui) returnFromContext() error {
|
||||
gui.g.Update(func(*gocui.Gui) error {
|
||||
return gui.returnFromContextSync()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) returnFromContextSync() error {
|
||||
gui.State.ContextManager.Lock()
|
||||
|
||||
if len(gui.State.ContextManager.ContextStack) == 1 {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (gui *Gui) contextTree() ContextTree {
|
||||
Key: MAIN_NORMAL_CONTEXT_KEY,
|
||||
},
|
||||
Staging: &BasicContext{
|
||||
OnRenderToMain: func(opts ...OnFocusOpts) error {
|
||||
OnFocus: func(opts ...OnFocusOpts) error {
|
||||
forceSecondaryFocused := false
|
||||
selectedLineIdx := -1
|
||||
if len(opts) > 0 && opts[0].ClickedViewName != "" {
|
||||
@@ -147,27 +147,27 @@ func (gui *Gui) contextTree() ContextTree {
|
||||
forceSecondaryFocused = true
|
||||
}
|
||||
}
|
||||
return gui.handleRefreshStagingPanel(forceSecondaryFocused, selectedLineIdx)
|
||||
return gui.onStagingFocus(forceSecondaryFocused, selectedLineIdx)
|
||||
},
|
||||
Kind: MAIN_CONTEXT,
|
||||
ViewName: "main",
|
||||
Key: MAIN_STAGING_CONTEXT_KEY,
|
||||
},
|
||||
PatchBuilding: &BasicContext{
|
||||
OnRenderToMain: func(opts ...OnFocusOpts) error {
|
||||
OnFocus: func(opts ...OnFocusOpts) error {
|
||||
selectedLineIdx := -1
|
||||
if len(opts) > 0 && (opts[0].ClickedViewName == "main" || opts[0].ClickedViewName == "secondary") {
|
||||
selectedLineIdx = opts[0].ClickedViewLineIdx
|
||||
}
|
||||
|
||||
return gui.handleRefreshPatchBuildingPanel(selectedLineIdx)
|
||||
return gui.onPatchBuildingFocus(selectedLineIdx)
|
||||
},
|
||||
Kind: MAIN_CONTEXT,
|
||||
ViewName: "main",
|
||||
Key: MAIN_PATCH_BUILDING_CONTEXT_KEY,
|
||||
},
|
||||
Merging: &BasicContext{
|
||||
OnFocus: OnFocusWrapper(gui.refreshMergePanelWithLock),
|
||||
OnFocus: OnFocusWrapper(func() error { return gui.renderConflictsWithLock(true) }),
|
||||
Kind: MAIN_CONTEXT,
|
||||
ViewName: "main",
|
||||
Key: MAIN_MERGING_CONTEXT_KEY,
|
||||
|
||||
@@ -3,7 +3,6 @@ package gui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -13,7 +12,7 @@ type credentials chan string
|
||||
// promptUserForCredential wait for a username, password or passphrase input from the credentials popup
|
||||
func (gui *Gui) promptUserForCredential(passOrUname oscommands.CredentialType) string {
|
||||
gui.credentials = make(chan string)
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
gui.OnUIThread(func() error {
|
||||
credentialsView := gui.Views.Credentials
|
||||
switch passOrUname {
|
||||
case oscommands.Username:
|
||||
@@ -68,8 +67,7 @@ func (gui *Gui) handleCredentialsViewFocused() error {
|
||||
},
|
||||
)
|
||||
|
||||
gui.renderString(gui.Views.Options, message)
|
||||
return nil
|
||||
return gui.renderString(gui.Views.Options, message)
|
||||
}
|
||||
|
||||
// handleCredentialsPopup handles the views after executing a command that might ask for credentials
|
||||
|
||||
@@ -254,7 +254,11 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
|
||||
}
|
||||
return gui.WithWaitingStatus(loadingText, func() error {
|
||||
gui.logAction(gui.Tr.Actions.CustomCommand)
|
||||
err := gui.OSCommand.Cmd.NewShell(cmdStr).Run()
|
||||
cmdObj := gui.OSCommand.Cmd.NewShell(cmdStr)
|
||||
if customCommand.Stream {
|
||||
cmdObj.StreamOutput()
|
||||
}
|
||||
err := cmdObj.Run()
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,25 @@ import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var CONTEXT_KEYS_SHOWING_DIFFS = []ContextKey{
|
||||
FILES_CONTEXT_KEY,
|
||||
COMMIT_FILES_CONTEXT_KEY,
|
||||
STASH_CONTEXT_KEY,
|
||||
BRANCH_COMMITS_CONTEXT_KEY,
|
||||
SUB_COMMITS_CONTEXT_KEY,
|
||||
MAIN_STAGING_CONTEXT_KEY,
|
||||
MAIN_PATCH_BUILDING_CONTEXT_KEY,
|
||||
}
|
||||
|
||||
func isShowingDiff(gui *Gui) bool {
|
||||
key := gui.currentStaticContext().GetKey()
|
||||
|
||||
return key == FILES_CONTEXT_KEY || key == COMMIT_FILES_CONTEXT_KEY || key == STASH_CONTEXT_KEY || key == BRANCH_COMMITS_CONTEXT_KEY || key == SUB_COMMITS_CONTEXT_KEY || key == MAIN_STAGING_CONTEXT_KEY || key == MAIN_PATCH_BUILDING_CONTEXT_KEY
|
||||
for _, contextKey := range CONTEXT_KEYS_SHOWING_DIFFS {
|
||||
if key == contextKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (gui *Gui) IncreaseContextInDiffView() error {
|
||||
@@ -17,7 +32,7 @@ func (gui *Gui) IncreaseContextInDiffView() error {
|
||||
}
|
||||
|
||||
gui.UserConfig.Git.DiffContextSize = gui.UserConfig.Git.DiffContextSize + 1
|
||||
return gui.currentStaticContext().HandleRenderToMain()
|
||||
return gui.handleDiffContextSizeChange()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -32,12 +47,25 @@ func (gui *Gui) DecreaseContextInDiffView() error {
|
||||
}
|
||||
|
||||
gui.UserConfig.Git.DiffContextSize = old_size - 1
|
||||
return gui.currentStaticContext().HandleRenderToMain()
|
||||
return gui.handleDiffContextSizeChange()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleDiffContextSizeChange() error {
|
||||
currentContext := gui.currentStaticContext()
|
||||
switch currentContext.GetKey() {
|
||||
// we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards.
|
||||
case MAIN_PATCH_BUILDING_CONTEXT_KEY:
|
||||
return gui.handleRefreshPatchBuildingPanel(-1)
|
||||
case MAIN_STAGING_CONTEXT_KEY:
|
||||
return gui.handleRefreshStagingPanel(false, -1)
|
||||
default:
|
||||
return currentContext.HandleRenderToMain()
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) CheckCanChangeContext() error {
|
||||
if gui.Git.Patch.PatchManager.Active() {
|
||||
return errors.New(gui.Tr.CantChangeContextSizeError)
|
||||
|
||||
@@ -27,6 +27,7 @@ func setupGuiForTest(gui *Gui) {
|
||||
gui.g = &gocui.Gui{}
|
||||
gui.Views.Main, _ = gui.prepareView("main")
|
||||
gui.Views.Secondary, _ = gui.prepareView("secondary")
|
||||
gui.Views.Options, _ = gui.prepareView("options")
|
||||
gui.Git.Patch.PatchManager = &patch.PatchManager{}
|
||||
_, _ = gui.refreshLineByLinePanel(diffForTest, "", false, 11)
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func TestIncreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
|
||||
context := c(gui)
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 1
|
||||
_ = gui.pushContextDirect(context)
|
||||
_ = gui.pushContext(context)
|
||||
|
||||
_ = gui.IncreaseContextInDiffView()
|
||||
|
||||
@@ -64,7 +65,9 @@ func TestDoesntIncreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
|
||||
func(gui *Gui) Context { return gui.State.Contexts.ReflogCommits },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.RemoteBranches },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.Tags },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.Merging },
|
||||
// not testing this because it will kick straight back to the files context
|
||||
// upon pushing the context
|
||||
// func(gui *Gui) Context { return gui.State.Contexts.Merging },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.CommandLog },
|
||||
}
|
||||
|
||||
@@ -73,7 +76,7 @@ func TestDoesntIncreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
|
||||
context := c(gui)
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 1
|
||||
_ = gui.pushContextDirect(context)
|
||||
_ = gui.pushContext(context)
|
||||
|
||||
_ = gui.IncreaseContextInDiffView()
|
||||
|
||||
@@ -97,7 +100,7 @@ func TestDecreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
|
||||
context := c(gui)
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 2
|
||||
_ = gui.pushContextDirect(context)
|
||||
_ = gui.pushContext(context)
|
||||
|
||||
_ = gui.DecreaseContextInDiffView()
|
||||
|
||||
@@ -114,7 +117,9 @@ func TestDoesntDecreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
|
||||
func(gui *Gui) Context { return gui.State.Contexts.ReflogCommits },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.RemoteBranches },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.Tags },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.Merging },
|
||||
// not testing this because it will kick straight back to the files context
|
||||
// upon pushing the context
|
||||
// func(gui *Gui) Context { return gui.State.Contexts.Merging },
|
||||
func(gui *Gui) Context { return gui.State.Contexts.CommandLog },
|
||||
}
|
||||
|
||||
@@ -123,7 +128,7 @@ func TestDoesntDecreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
|
||||
context := c(gui)
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 2
|
||||
_ = gui.pushContextDirect(context)
|
||||
_ = gui.pushContext(context)
|
||||
|
||||
_ = gui.DecreaseContextInDiffView()
|
||||
|
||||
@@ -135,7 +140,7 @@ func TestDoesntIncreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *test
|
||||
gui := NewDummyGui()
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 2
|
||||
_ = gui.pushContextDirect(gui.State.Contexts.CommitFiles)
|
||||
_ = gui.pushContext(gui.State.Contexts.CommitFiles)
|
||||
gui.Git.Patch.PatchManager.Start("from", "to", false, false)
|
||||
|
||||
errorCount := 0
|
||||
@@ -157,7 +162,7 @@ func TestDoesntDecreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *test
|
||||
gui := NewDummyGui()
|
||||
setupGuiForTest(gui)
|
||||
gui.UserConfig.Git.DiffContextSize = 2
|
||||
_ = gui.pushContextDirect(gui.State.Contexts.CommitFiles)
|
||||
_ = gui.pushContext(gui.State.Contexts.CommitFiles)
|
||||
gui.Git.Patch.PatchManager.Start("from", "to", false, false)
|
||||
|
||||
errorCount := 0
|
||||
|
||||
@@ -44,8 +44,8 @@ func (gui *Gui) currentDiffTerminals() []string {
|
||||
branch := gui.getSelectedBranch()
|
||||
if branch != nil {
|
||||
names := []string{branch.ID()}
|
||||
if branch.UpstreamName != "" {
|
||||
names = append(names, branch.UpstreamName)
|
||||
if branch.IsTrackingRemote() {
|
||||
names = append(names, branch.ID()+"@{u}")
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -22,7 +23,7 @@ func (gui *Gui) getSelectedFileNode() *filetree.FileNode {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.State.FileManager.GetItemAtIndex(selectedLine)
|
||||
return gui.State.FileTreeViewModel.GetItemAtIndex(selectedLine)
|
||||
}
|
||||
|
||||
func (gui *Gui) getSelectedFile() *models.File {
|
||||
@@ -55,9 +56,17 @@ func (gui *Gui) filesRenderToMain() error {
|
||||
}
|
||||
|
||||
if node.File != nil && node.File.HasInlineMergeConflicts {
|
||||
return gui.refreshMergePanelWithLock()
|
||||
ok, err := gui.setConflictsAndRenderWithLock(node.GetPath(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
gui.resetMergeStateWithLock()
|
||||
|
||||
cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, !node.GetHasUnstagedChanges() && node.GetHasStagedChanges(), gui.State.IgnoreWhitespaceInDiffView)
|
||||
|
||||
refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
|
||||
@@ -89,16 +98,21 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
gui.Mutexes.RefreshingFilesMutex.Unlock()
|
||||
}()
|
||||
|
||||
selectedPath := gui.getSelectedPath()
|
||||
prevSelectedPath := gui.getSelectedPath()
|
||||
|
||||
if err := gui.refreshStateSubmoduleConfigs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gui.refreshMergeState(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gui.refreshStateFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
gui.OnUIThread(func() error {
|
||||
if err := gui.postRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
|
||||
gui.Log.Error(err)
|
||||
}
|
||||
@@ -110,9 +124,9 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
}
|
||||
}
|
||||
|
||||
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY || (g.CurrentView() == gui.Views.Main && ContextKey(g.CurrentView().Context) == MAIN_MERGING_CONTEXT_KEY) {
|
||||
newSelectedPath := gui.getSelectedPath()
|
||||
alreadySelected := selectedPath != "" && newSelectedPath == selectedPath
|
||||
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY {
|
||||
currentSelectedPath := gui.getSelectedPath()
|
||||
alreadySelected := prevSelectedPath != "" && currentSelectedPath == prevSelectedPath
|
||||
if !alreadySelected {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
}
|
||||
@@ -130,7 +144,7 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
// specific functions
|
||||
|
||||
func (gui *Gui) stagedFiles() []*models.File {
|
||||
files := gui.State.FileManager.GetAllFiles()
|
||||
files := gui.State.FileTreeViewModel.GetAllFiles()
|
||||
result := make([]*models.File, 0)
|
||||
for _, file := range files {
|
||||
if file.HasStagedChanges {
|
||||
@@ -141,7 +155,7 @@ func (gui *Gui) stagedFiles() []*models.File {
|
||||
}
|
||||
|
||||
func (gui *Gui) trackedFiles() []*models.File {
|
||||
files := gui.State.FileManager.GetAllFiles()
|
||||
files := gui.State.FileTreeViewModel.GetAllFiles()
|
||||
result := make([]*models.File, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file.Tracked {
|
||||
@@ -151,15 +165,6 @@ func (gui *Gui) trackedFiles() []*models.File {
|
||||
return result
|
||||
}
|
||||
|
||||
func (gui *Gui) stageSelectedFile() error {
|
||||
file := gui.getSelectedFile()
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.Git.WorkingTree.StageFile(file.Name)
|
||||
}
|
||||
|
||||
func (gui *Gui) handleEnterFile() error {
|
||||
return gui.enterFile(OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
|
||||
}
|
||||
@@ -183,7 +188,7 @@ func (gui *Gui) enterFile(opts OnFocusOpts) error {
|
||||
}
|
||||
|
||||
if file.HasInlineMergeConflicts {
|
||||
return gui.handleSwitchToMerge()
|
||||
return gui.switchToMerge()
|
||||
}
|
||||
if file.HasMergeConflicts {
|
||||
return gui.createErrorPanel(gui.Tr.FileStagingRequirements)
|
||||
@@ -202,7 +207,7 @@ func (gui *Gui) handleFilePress() error {
|
||||
file := node.File
|
||||
|
||||
if file.HasInlineMergeConflicts {
|
||||
return gui.handleSwitchToMerge()
|
||||
return gui.switchToMerge()
|
||||
}
|
||||
|
||||
if file.HasUnstagedChanges {
|
||||
@@ -245,7 +250,7 @@ func (gui *Gui) handleFilePress() error {
|
||||
}
|
||||
|
||||
func (gui *Gui) allFilesStaged() bool {
|
||||
for _, file := range gui.State.FileManager.GetAllFiles() {
|
||||
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
|
||||
if file.HasUnstagedChanges {
|
||||
return false
|
||||
}
|
||||
@@ -379,7 +384,7 @@ func (gui *Gui) handleCommitPress() error {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if gui.State.FileManager.GetItemsLength() == 0 {
|
||||
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
||||
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
@@ -407,14 +412,11 @@ func (gui *Gui) handleCommitPress() error {
|
||||
}
|
||||
}
|
||||
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
if err := gui.pushContext(gui.State.Contexts.CommitMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gui.pushContext(gui.State.Contexts.CommitMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gui.RenderCommitLength()
|
||||
return nil
|
||||
})
|
||||
gui.RenderCommitLength()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -437,7 +439,7 @@ func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
|
||||
}
|
||||
|
||||
func (gui *Gui) handleAmendCommitPress() error {
|
||||
if gui.State.FileManager.GetItemsLength() == 0 {
|
||||
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
||||
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
@@ -463,7 +465,7 @@ func (gui *Gui) handleAmendCommitPress() error {
|
||||
// handleCommitEditorPress - handle when the user wants to commit changes via
|
||||
// their editor rather than via the popup panel
|
||||
func (gui *Gui) handleCommitEditorPress() error {
|
||||
if gui.State.FileManager.GetItemsLength() == 0 {
|
||||
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
||||
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
@@ -502,9 +504,9 @@ func (gui *Gui) handleStatusFilterPressed() error {
|
||||
return gui.createMenu(gui.Tr.FilteringMenuTitle, menuItems, createMenuOptions{showCancel: true})
|
||||
}
|
||||
|
||||
func (gui *Gui) setStatusFiltering(filter filetree.FileManagerDisplayFilter) error {
|
||||
func (gui *Gui) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
|
||||
state := gui.State
|
||||
state.FileManager.SetDisplayFilter(filter)
|
||||
state.FileTreeViewModel.SetFilter(filter)
|
||||
return gui.handleRefreshFiles()
|
||||
}
|
||||
|
||||
@@ -559,31 +561,85 @@ func (gui *Gui) refreshStateFiles() error {
|
||||
|
||||
selectedNode := gui.getSelectedFileNode()
|
||||
|
||||
prevNodes := gui.State.FileManager.GetAllItems()
|
||||
prevNodes := gui.State.FileTreeViewModel.GetAllItems()
|
||||
prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
|
||||
|
||||
// If git thinks any of our files have inline merge conflicts, but they actually don't,
|
||||
// we stage them.
|
||||
// Note that if files with merge conflicts have both arisen and have been resolved
|
||||
// between refreshes, we won't stage them here. This is super unlikely though,
|
||||
// and this approach spares us from having to call `git status` twice in a row.
|
||||
// Although this also means that at startup we won't be staging anything until
|
||||
// we call git status again.
|
||||
pathsToStage := []string{}
|
||||
prevConflictFileCount := 0
|
||||
for _, file := range state.FileTreeViewModel.GetAllFiles() {
|
||||
if file.HasMergeConflicts {
|
||||
prevConflictFileCount++
|
||||
}
|
||||
if file.HasInlineMergeConflicts {
|
||||
hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
|
||||
if err != nil {
|
||||
gui.Log.Error(err)
|
||||
} else if !hasConflicts {
|
||||
pathsToStage = append(pathsToStage, file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(pathsToStage) > 0 {
|
||||
gui.logAction(gui.Tr.Actions.StageResolvedFiles)
|
||||
if err := gui.Git.WorkingTree.StageFiles(pathsToStage); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
files := gui.Git.Loaders.Files.
|
||||
GetStatusFiles(loaders.GetStatusFileOptions{})
|
||||
|
||||
// for when you stage the old file of a rename and the new file is in a collapsed dir
|
||||
state.FileManager.RWMutex.Lock()
|
||||
conflictFileCount := 0
|
||||
for _, file := range files {
|
||||
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
|
||||
state.FileManager.ExpandToPath(file.Name)
|
||||
if file.HasMergeConflicts {
|
||||
conflictFileCount++
|
||||
}
|
||||
}
|
||||
|
||||
state.FileManager.SetFiles(files)
|
||||
state.FileManager.RWMutex.Unlock()
|
||||
if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && conflictFileCount == 0 && prevConflictFileCount > 0 {
|
||||
gui.OnUIThread(func() error { return gui.promptToContinueRebase() })
|
||||
}
|
||||
|
||||
// for when you stage the old file of a rename and the new file is in a collapsed dir
|
||||
state.FileTreeViewModel.RWMutex.Lock()
|
||||
for _, file := range files {
|
||||
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
|
||||
state.FileTreeViewModel.ExpandToPath(file.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// only taking over the filter if it hasn't already been set by the user.
|
||||
// Though this does make it impossible for the user to actually say they want to display all if
|
||||
// conflicts are currently being shown. Hmm. Worth it I reckon. If we need to add some
|
||||
// extra state here to see if the user's set the filter themselves we can do that, but
|
||||
// I'd prefer to maintain as little state as possible.
|
||||
if conflictFileCount > 0 {
|
||||
if state.FileTreeViewModel.GetFilter() == filetree.DisplayAll {
|
||||
state.FileTreeViewModel.SetFilter(filetree.DisplayConflicted)
|
||||
}
|
||||
} else if state.FileTreeViewModel.GetFilter() == filetree.DisplayConflicted {
|
||||
state.FileTreeViewModel.SetFilter(filetree.DisplayAll)
|
||||
}
|
||||
|
||||
state.FileTreeViewModel.SetFiles(files)
|
||||
state.FileTreeViewModel.RWMutex.Unlock()
|
||||
|
||||
if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if selectedNode != nil {
|
||||
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileManager.GetAllItems())
|
||||
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileTreeViewModel.GetAllItems())
|
||||
if newIdx != -1 && newIdx != prevSelectedLineIdx {
|
||||
newNode := state.FileManager.GetItemAtIndex(newIdx)
|
||||
newNode := state.FileTreeViewModel.GetItemAtIndex(newIdx)
|
||||
// when not in tree mode, we show merge conflict files at the top, so you
|
||||
// can work through them one by one without having to sift through a large
|
||||
// set of files. If you have just fixed the merge conflicts of a file, we
|
||||
@@ -592,7 +648,7 @@ func (gui *Gui) refreshStateFiles() error {
|
||||
// conflicts: the user in this case would rather work on the next file
|
||||
// with merge conflicts, which will have moved up to fill the gap left by
|
||||
// the last file, meaning the cursor doesn't need to move at all.
|
||||
leaveCursor := !state.FileManager.InTreeMode() && newNode != nil &&
|
||||
leaveCursor := !state.FileTreeViewModel.InTreeMode() && newNode != nil &&
|
||||
selectedNode.File != nil && selectedNode.File.HasMergeConflicts &&
|
||||
newNode.File != nil && !newNode.File.HasMergeConflicts
|
||||
|
||||
@@ -602,10 +658,23 @@ func (gui *Gui) refreshStateFiles() error {
|
||||
}
|
||||
}
|
||||
|
||||
gui.refreshSelectedLine(state.Panels.Files, state.FileManager.GetItemsLength())
|
||||
gui.refreshSelectedLine(state.Panels.Files, state.FileTreeViewModel.GetItemsLength())
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
||||
func (gui *Gui) promptToContinueRebase() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
return gui.ask(askOpts{
|
||||
title: "continue",
|
||||
prompt: gui.Tr.ConflictsResolved,
|
||||
handleConfirm: func() error {
|
||||
return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Let's try to find our file again and move the cursor to that.
|
||||
// If we can't find our file, it was probably just removed by the user. In that
|
||||
// case, we go looking for where the next file has been moved to. Given that the
|
||||
@@ -659,42 +728,40 @@ func (gui *Gui) handlePullFiles() error {
|
||||
|
||||
// if we have no upstream branch we need to set that first
|
||||
if !currentBranch.IsTrackingRemote() {
|
||||
// see if we have this branch in our config with an upstream
|
||||
branches, err := gui.Git.Config.Branches()
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
for branchName, branch := range branches {
|
||||
if branchName == currentBranch.Name {
|
||||
return gui.pullFiles(PullFilesOptions{RemoteName: branch.Remote, BranchName: branch.Name, action: action})
|
||||
}
|
||||
}
|
||||
|
||||
suggestedRemote := getSuggestedRemote(gui.State.Remotes)
|
||||
|
||||
return gui.prompt(promptOpts{
|
||||
title: gui.Tr.EnterUpstream,
|
||||
initialContent: suggestedRemote + "/" + currentBranch.Name,
|
||||
findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc("/"),
|
||||
initialContent: suggestedRemote + " " + currentBranch.Name,
|
||||
findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "),
|
||||
handleConfirm: func(upstream string) error {
|
||||
if err := gui.Git.Branch.SetCurrentBranchUpstream(upstream); err != nil {
|
||||
var upstreamBranch, upstreamRemote string
|
||||
split := strings.Split(upstream, " ")
|
||||
if len(split) != 2 {
|
||||
return gui.createErrorPanel(gui.Tr.InvalidUpstream)
|
||||
}
|
||||
|
||||
upstreamRemote = split[0]
|
||||
upstreamBranch = split[1]
|
||||
|
||||
if err := gui.Git.Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
|
||||
errorMessage := err.Error()
|
||||
if strings.Contains(errorMessage, "does not exist") {
|
||||
errorMessage = fmt.Sprintf("upstream branch %s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')", upstream)
|
||||
}
|
||||
return gui.createErrorPanel(errorMessage)
|
||||
}
|
||||
return gui.pullFiles(PullFilesOptions{action: action})
|
||||
return gui.pullFiles(PullFilesOptions{UpstreamRemote: upstreamRemote, UpstreamBranch: upstreamBranch, action: action})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return gui.pullFiles(PullFilesOptions{action: action})
|
||||
return gui.pullFiles(PullFilesOptions{UpstreamRemote: currentBranch.UpstreamRemote, UpstreamBranch: currentBranch.UpstreamBranch, action: action})
|
||||
}
|
||||
|
||||
type PullFilesOptions struct {
|
||||
RemoteName string
|
||||
BranchName string
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
FastForwardOnly bool
|
||||
action string
|
||||
}
|
||||
@@ -718,8 +785,8 @@ func (gui *Gui) pullWithLock(opts PullFilesOptions) error {
|
||||
|
||||
err := gui.Git.Sync.Pull(
|
||||
git_commands.PullOptions{
|
||||
RemoteName: opts.RemoteName,
|
||||
BranchName: opts.BranchName,
|
||||
RemoteName: opts.UpstreamRemote,
|
||||
BranchName: opts.UpstreamBranch,
|
||||
FastForwardOnly: opts.FastForwardOnly,
|
||||
},
|
||||
)
|
||||
@@ -786,28 +853,18 @@ func (gui *Gui) pushFiles() error {
|
||||
}
|
||||
|
||||
if currentBranch.IsTrackingRemote() {
|
||||
opts := pushOpts{
|
||||
force: false,
|
||||
upstreamRemote: currentBranch.UpstreamRemote,
|
||||
upstreamBranch: currentBranch.UpstreamBranch,
|
||||
}
|
||||
if currentBranch.HasCommitsToPull() {
|
||||
return gui.requestToForcePush()
|
||||
opts.force = true
|
||||
return gui.requestToForcePush(opts)
|
||||
} else {
|
||||
return gui.push(pushOpts{})
|
||||
return gui.push(opts)
|
||||
}
|
||||
} else {
|
||||
// see if we have an upstream for this branch in our config
|
||||
upstreamRemote, upstreamBranch, err := gui.upstreamForBranchInConfig(currentBranch.Name)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if upstreamBranch != "" {
|
||||
return gui.push(
|
||||
pushOpts{
|
||||
force: false,
|
||||
upstreamRemote: upstreamRemote,
|
||||
upstreamBranch: upstreamBranch,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suggestedRemote := getSuggestedRemote(gui.State.Remotes)
|
||||
|
||||
if gui.Git.Config.GetPushToCurrent() {
|
||||
@@ -854,7 +911,7 @@ func getSuggestedRemote(remotes []*models.Remote) string {
|
||||
return remotes[0].Name
|
||||
}
|
||||
|
||||
func (gui *Gui) requestToForcePush() error {
|
||||
func (gui *Gui) requestToForcePush(opts pushOpts) error {
|
||||
forcePushDisabled := gui.UserConfig.Git.DisableForcePushing
|
||||
if forcePushDisabled {
|
||||
return gui.createErrorPanel(gui.Tr.ForcePushDisabled)
|
||||
@@ -864,34 +921,27 @@ func (gui *Gui) requestToForcePush() error {
|
||||
title: gui.Tr.ForcePush,
|
||||
prompt: gui.Tr.ForcePushPrompt,
|
||||
handleConfirm: func() error {
|
||||
return gui.push(pushOpts{force: true})
|
||||
return gui.push(opts)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) upstreamForBranchInConfig(branchName string) (string, string, error) {
|
||||
branches, err := gui.Git.Config.Branches()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
for configBranchName, configBranch := range branches {
|
||||
if configBranchName == branchName {
|
||||
return configBranch.Remote, configBranchName, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleSwitchToMerge() error {
|
||||
func (gui *Gui) switchToMerge() error {
|
||||
file := gui.getSelectedFile()
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !file.HasInlineMergeConflicts {
|
||||
return gui.createErrorPanel(gui.Tr.FileNoMergeCons)
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
if gui.State.Panels.Merging.GetPath() != file.Name {
|
||||
hasConflicts, err := gui.setMergeStateWithLock(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasConflicts {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return gui.pushContext(gui.State.Contexts.Merging)
|
||||
@@ -905,15 +955,6 @@ func (gui *Gui) openFile(filename string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) anyFilesWithMergeConflicts() bool {
|
||||
for _, file := range gui.State.FileManager.GetAllFiles() {
|
||||
if file.HasMergeConflicts {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCustomCommand() error {
|
||||
return gui.prompt(promptOpts{
|
||||
title: gui.Tr.CustomCommand,
|
||||
@@ -974,7 +1015,7 @@ func (gui *Gui) handleToggleDirCollapsed() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
gui.State.FileManager.ToggleCollapsed(node.GetPath())
|
||||
gui.State.FileTreeViewModel.ToggleCollapsed(node.GetPath())
|
||||
|
||||
if err := gui.postRefreshUpdate(gui.State.Contexts.Files); err != nil {
|
||||
gui.Log.Error(err)
|
||||
@@ -987,12 +1028,12 @@ func (gui *Gui) handleToggleFileTreeView() error {
|
||||
// get path of currently selected file
|
||||
path := gui.getSelectedPath()
|
||||
|
||||
gui.State.FileManager.ToggleShowTree()
|
||||
gui.State.FileTreeViewModel.ToggleShowTree()
|
||||
|
||||
// find that same node in the new format and move the cursor to it
|
||||
if path != "" {
|
||||
gui.State.FileManager.ExpandToPath(path)
|
||||
index, found := gui.State.FileManager.GetIndexForPath(path)
|
||||
gui.State.FileTreeViewModel.ExpandToPath(path)
|
||||
index, found := gui.State.FileTreeViewModel.GetIndexForPath(path)
|
||||
if found {
|
||||
gui.filesListContext().GetPanelState().SetSelectedLineIdx(index)
|
||||
}
|
||||
|
||||
22
pkg/gui/filetree/README.md
Normal file
22
pkg/gui/filetree/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## FileTree Package
|
||||
|
||||
This package handles the representation of file trees. There are two ways to render files: one is to render them flat, so something like this:
|
||||
|
||||
```
|
||||
dir1/file1
|
||||
dir1/file2
|
||||
file3
|
||||
```
|
||||
|
||||
And the other is to render them as a tree
|
||||
|
||||
```
|
||||
dir1/
|
||||
file1
|
||||
file2
|
||||
file3
|
||||
```
|
||||
|
||||
Internally we represent each of the above as a tree, but with the flat approach there's just a single root node and every path is a direct child of that root. Viewing in 'tree' mode (as opposed to 'flat' mode) allows for collapsing and expanding directories, and lets you perform actions on directories e.g. staging a whole directory. But it takes up more vertical space and sometimes you just want to have a flat view where you can go flick through your files one by one to see the diff.
|
||||
|
||||
This package is not concerned about rendering the tree: only representing its internal state.
|
||||
@@ -1,119 +0,0 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type CommitFileManager struct {
|
||||
files []*models.CommitFile
|
||||
tree *CommitFileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
collapsedPaths CollapsedPaths
|
||||
// parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
|
||||
parent string
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetParent() string {
|
||||
return m.parent
|
||||
}
|
||||
|
||||
func NewCommitFileManager(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileManager {
|
||||
return &CommitFileManager{
|
||||
files: files,
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) ExpandToPath(path string) {
|
||||
m.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) ToggleShowTree() {
|
||||
m.showTree = !m.showTree
|
||||
m.SetTree()
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetItemAtIndex(index int) *CommitFileNode {
|
||||
// need to traverse the three depth first until we get to the index.
|
||||
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetAllItems() []*CommitFileNode {
|
||||
if m.tree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetItemsLength() int {
|
||||
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) GetAllFiles() []*models.CommitFile {
|
||||
return m.files
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) SetFiles(files []*models.CommitFile, parent string) {
|
||||
m.files = files
|
||||
m.parent = parent
|
||||
|
||||
m.SetTree()
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) SetTree() {
|
||||
if m.showTree {
|
||||
m.tree = BuildTreeFromCommitFiles(m.files)
|
||||
} else {
|
||||
m.tree = BuildFlatTreeFromCommitFiles(m.files)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) IsCollapsed(path string) bool {
|
||||
return m.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) ToggleCollapsed(path string) {
|
||||
m.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (m *CommitFileManager) Render(diffName string, patchManager *patch.PatchManager) []string {
|
||||
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
|
||||
if m.tree == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
|
||||
castN := n.(*CommitFileNode)
|
||||
|
||||
// This is a little convoluted because we're dealing with either a leaf or a non-leaf.
|
||||
// But this code actually applies to both. If it's a leaf, the status will just
|
||||
// be whatever status it is, but if it's a non-leaf it will determine its status
|
||||
// based on the leaves of that subtree
|
||||
var status patch.PatchStatus
|
||||
if castN.EveryFile(func(file *models.CommitFile) bool {
|
||||
return patchManager.GetFileStatus(file.Name, m.parent) == patch.WHOLE
|
||||
}) {
|
||||
status = patch.WHOLE
|
||||
} else if castN.EveryFile(func(file *models.CommitFile) bool {
|
||||
return patchManager.GetFileStatus(file.Name, m.parent) == patch.UNSELECTED
|
||||
}) {
|
||||
status = patch.UNSELECTED
|
||||
} else {
|
||||
status = patch.PART
|
||||
}
|
||||
|
||||
return presentation.GetCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,8 @@ type CommitFileNode struct {
|
||||
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
|
||||
}
|
||||
|
||||
var _ INode = &CommitFileNode{}
|
||||
|
||||
// methods satisfying ListItem interface
|
||||
|
||||
func (s *CommitFileNode) ID() string {
|
||||
@@ -23,6 +25,10 @@ func (s *CommitFileNode) Description() string {
|
||||
|
||||
// methods satisfying INode interface
|
||||
|
||||
func (s *CommitFileNode) IsNil() bool {
|
||||
return s == nil
|
||||
}
|
||||
|
||||
func (s *CommitFileNode) IsLeaf() bool {
|
||||
return s.File != nil
|
||||
}
|
||||
@@ -139,13 +145,6 @@ func (s *CommitFileNode) Compress() {
|
||||
compressAux(s)
|
||||
}
|
||||
|
||||
// This ignores the root
|
||||
func (node *CommitFileNode) GetPathsMatching(test func(*CommitFileNode) bool) []string {
|
||||
return getPathsMatching(node, func(n INode) bool {
|
||||
return test(n.(*CommitFileNode))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CommitFileNode) GetLeaves() []*CommitFileNode {
|
||||
leaves := getLeaves(s)
|
||||
castLeaves := make([]*CommitFileNode, len(leaves))
|
||||
|
||||
101
pkg/gui/filetree/commit_file_tree_view_model.go
Normal file
101
pkg/gui/filetree/commit_file_tree_view_model.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type CommitFileTreeViewModel struct {
|
||||
files []*models.CommitFile
|
||||
tree *CommitFileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
collapsedPaths CollapsedPaths
|
||||
// parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
|
||||
parent string
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetParent() string {
|
||||
return self.parent
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) SetParent(parent string) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func NewCommitFileTreeViewModel(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel {
|
||||
viewModel := &CommitFileTreeViewModel{
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
}
|
||||
|
||||
viewModel.SetFiles(files)
|
||||
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) ExpandToPath(path string) {
|
||||
self.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) ToggleShowTree() {
|
||||
self.showTree = !self.showTree
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetItemAtIndex(index int) *CommitFileNode {
|
||||
// need to traverse the three depth first until we get to the index.
|
||||
return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetAllItems() []*CommitFileNode {
|
||||
if self.tree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetItemsLength() int {
|
||||
return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) GetAllFiles() []*models.CommitFile {
|
||||
return self.files
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) SetFiles(files []*models.CommitFile) {
|
||||
self.files = files
|
||||
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) SetTree() {
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromCommitFiles(self.files)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromCommitFiles(self.files)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) IsCollapsed(path string) bool {
|
||||
return self.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) ToggleCollapsed(path string) {
|
||||
self.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) Tree() INode {
|
||||
return self.tree
|
||||
}
|
||||
|
||||
func (self *CommitFileTreeViewModel) CollapsedPaths() CollapsedPaths {
|
||||
return self.collapsedPaths
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package filetree
|
||||
|
||||
const EXPANDED_ARROW = "▼"
|
||||
const COLLAPSED_ARROW = "►"
|
||||
|
||||
const INNER_ITEM = "├─ "
|
||||
const LAST_ITEM = "└─ "
|
||||
const NESTED = "│ "
|
||||
const NOTHING = " "
|
||||
@@ -1,141 +0,0 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type FileManagerDisplayFilter int
|
||||
|
||||
const (
|
||||
DisplayAll FileManagerDisplayFilter = iota
|
||||
DisplayStaged
|
||||
DisplayUnstaged
|
||||
)
|
||||
|
||||
type FileManager struct {
|
||||
files []*models.File
|
||||
tree *FileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
filter FileManagerDisplayFilter
|
||||
collapsedPaths CollapsedPaths
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func NewFileManager(files []*models.File, log *logrus.Entry, showTree bool) *FileManager {
|
||||
return &FileManager{
|
||||
files: files,
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
RWMutex: sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FileManager) InTreeMode() bool {
|
||||
return m.showTree
|
||||
}
|
||||
|
||||
func (m *FileManager) ExpandToPath(path string) {
|
||||
m.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (m *FileManager) GetFilesForDisplay() []*models.File {
|
||||
files := m.files
|
||||
if m.filter == DisplayAll {
|
||||
return files
|
||||
}
|
||||
|
||||
result := make([]*models.File, 0)
|
||||
if m.filter == DisplayStaged {
|
||||
for _, file := range files {
|
||||
if file.HasStagedChanges {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, file := range files {
|
||||
if !file.HasStagedChanges {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *FileManager) SetDisplayFilter(filter FileManagerDisplayFilter) {
|
||||
m.filter = filter
|
||||
m.SetTree()
|
||||
}
|
||||
|
||||
func (m *FileManager) ToggleShowTree() {
|
||||
m.showTree = !m.showTree
|
||||
m.SetTree()
|
||||
}
|
||||
|
||||
func (m *FileManager) GetItemAtIndex(index int) *FileNode {
|
||||
// need to traverse the three depth first until we get to the index.
|
||||
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
|
||||
}
|
||||
|
||||
func (m *FileManager) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
func (m *FileManager) GetAllItems() []*FileNode {
|
||||
if m.tree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (m *FileManager) GetItemsLength() int {
|
||||
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (m *FileManager) GetAllFiles() []*models.File {
|
||||
return m.files
|
||||
}
|
||||
|
||||
func (m *FileManager) SetFiles(files []*models.File) {
|
||||
m.files = files
|
||||
|
||||
m.SetTree()
|
||||
}
|
||||
|
||||
func (m *FileManager) SetTree() {
|
||||
filesForDisplay := m.GetFilesForDisplay()
|
||||
if m.showTree {
|
||||
m.tree = BuildTreeFromFiles(filesForDisplay)
|
||||
} else {
|
||||
m.tree = BuildFlatTreeFromFiles(filesForDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FileManager) IsCollapsed(path string) bool {
|
||||
return m.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (m *FileManager) ToggleCollapsed(path string) {
|
||||
m.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (m *FileManager) Render(diffName string, submoduleConfigs []*models.SubmoduleConfig) []string {
|
||||
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
|
||||
if m.tree == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
|
||||
castN := n.(*FileNode)
|
||||
return presentation.GetFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File)
|
||||
})
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gookit/color"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func init() {
|
||||
color.ForceSetColorLevel(terminfo.ColorLevelNone)
|
||||
}
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
root *FileNode
|
||||
collapsedPaths map[string]bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "nil node",
|
||||
root: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "leaf node",
|
||||
root: &FileNode{
|
||||
Path: "",
|
||||
Children: []*FileNode{
|
||||
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
|
||||
},
|
||||
},
|
||||
expected: []string{" M test"},
|
||||
},
|
||||
{
|
||||
name: "big example",
|
||||
root: &FileNode{
|
||||
Path: "",
|
||||
Children: []*FileNode{
|
||||
{
|
||||
Path: "dir1",
|
||||
Children: []*FileNode{
|
||||
{
|
||||
File: &models.File{Name: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
Path: "dir1/file2",
|
||||
},
|
||||
{
|
||||
File: &models.File{Name: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
Path: "dir1/file3",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: "dir2",
|
||||
Children: []*FileNode{
|
||||
{
|
||||
Path: "dir2/dir2",
|
||||
Children: []*FileNode{
|
||||
{
|
||||
File: &models.File{Name: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
|
||||
Path: "dir2/dir2/file3",
|
||||
},
|
||||
{
|
||||
File: &models.File{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
Path: "dir2/dir2/file4",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
File: &models.File{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
Path: "dir2/file5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
File: &models.File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
Path: "file1",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"dir1 ►", "dir2 ▼", "├─ dir2 ▼", "│ ├─ M file3", "│ └─ M file4", "└─ M file5", "M file1"},
|
||||
collapsedPaths: map[string]bool{"dir1": true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
mngr := &FileManager{tree: s.root, collapsedPaths: s.collapsedPaths}
|
||||
result := mngr.Render("", nil)
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAction(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
filter FileManagerDisplayFilter
|
||||
files []*models.File
|
||||
expected []*models.File
|
||||
}{
|
||||
{
|
||||
name: "filter files with unstaged changes",
|
||||
filter: DisplayUnstaged,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter files with staged changes",
|
||||
filter: DisplayStaged,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
|
||||
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter all files",
|
||||
filter: DisplayAll,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
mngr := &FileManager{files: s.files, filter: s.filter}
|
||||
result := mngr.GetFilesForDisplay()
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ type FileNode struct {
|
||||
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
|
||||
}
|
||||
|
||||
var _ INode = &FileNode{}
|
||||
|
||||
// methods satisfying ListItem interface
|
||||
|
||||
func (s *FileNode) ID() string {
|
||||
@@ -23,6 +25,12 @@ func (s *FileNode) Description() string {
|
||||
|
||||
// methods satisfying INode interface
|
||||
|
||||
// interfaces values whose concrete value is nil are not themselves nil
|
||||
// hence the existence of this method
|
||||
func (s *FileNode) IsNil() bool {
|
||||
return s == nil
|
||||
}
|
||||
|
||||
func (s *FileNode) IsLeaf() bool {
|
||||
return s.File != nil
|
||||
}
|
||||
@@ -124,10 +132,13 @@ func (s *FileNode) Compress() {
|
||||
compressAux(s)
|
||||
}
|
||||
|
||||
// This ignores the root
|
||||
func (node *FileNode) GetPathsMatching(test func(*FileNode) bool) []string {
|
||||
func (node *FileNode) GetFilePathsMatching(test func(*models.File) bool) []string {
|
||||
return getPathsMatching(node, func(n INode) bool {
|
||||
return test(n.(*FileNode))
|
||||
castNode := n.(*FileNode)
|
||||
if castNode.File == nil {
|
||||
return false
|
||||
}
|
||||
return test(castNode.File)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,32 @@ func TestCompress(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
viewModel *FileTreeViewModel
|
||||
path string
|
||||
expected *models.File
|
||||
}{
|
||||
{
|
||||
name: "valid case",
|
||||
viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
|
||||
path: "blah/two",
|
||||
expected: &models.File{Name: "blah/two"},
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
|
||||
path: "blah/three",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.EqualValues(t, s.expected, s.viewModel.GetFile(s.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
159
pkg/gui/filetree/file_tree_view_model.go
Normal file
159
pkg/gui/filetree/file_tree_view_model.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type FileTreeDisplayFilter int
|
||||
|
||||
const (
|
||||
DisplayAll FileTreeDisplayFilter = iota
|
||||
DisplayStaged
|
||||
DisplayUnstaged
|
||||
// this shows files with merge conflicts
|
||||
DisplayConflicted
|
||||
)
|
||||
|
||||
type FileTreeViewModel struct {
|
||||
files []*models.File
|
||||
tree *FileNode
|
||||
showTree bool
|
||||
log *logrus.Entry
|
||||
filter FileTreeDisplayFilter
|
||||
collapsedPaths CollapsedPaths
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func NewFileTreeViewModel(files []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
|
||||
viewModel := &FileTreeViewModel{
|
||||
log: log,
|
||||
showTree: showTree,
|
||||
filter: DisplayAll,
|
||||
collapsedPaths: CollapsedPaths{},
|
||||
RWMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
viewModel.SetFiles(files)
|
||||
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) InTreeMode() bool {
|
||||
return self.showTree
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ExpandToPath(path string) {
|
||||
self.collapsedPaths.ExpandToPath(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFilesForDisplay() []*models.File {
|
||||
files := self.files
|
||||
|
||||
switch self.filter {
|
||||
case DisplayAll:
|
||||
return files
|
||||
case DisplayStaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
|
||||
case DisplayUnstaged:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
|
||||
case DisplayConflicted:
|
||||
return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
|
||||
default:
|
||||
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) FilterFiles(test func(*models.File) bool) []*models.File {
|
||||
result := make([]*models.File, 0)
|
||||
for _, file := range self.files {
|
||||
if test(file) {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetFilter(filter FileTreeDisplayFilter) {
|
||||
self.filter = filter
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ToggleShowTree() {
|
||||
self.showTree = !self.showTree
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetItemAtIndex(index int) *FileNode {
|
||||
// need to traverse the three depth first until we get to the index.
|
||||
return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFile(path string) *models.File {
|
||||
for _, file := range self.files {
|
||||
if file.Name == path {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
|
||||
return index - 1, found
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetAllItems() []*FileNode {
|
||||
if self.tree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetItemsLength() int {
|
||||
return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetAllFiles() []*models.File {
|
||||
return self.files
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetFiles(files []*models.File) {
|
||||
self.files = files
|
||||
|
||||
self.SetTree()
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) SetTree() {
|
||||
filesForDisplay := self.GetFilesForDisplay()
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromFiles(filesForDisplay)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) IsCollapsed(path string) bool {
|
||||
return self.collapsedPaths.IsCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) ToggleCollapsed(path string) {
|
||||
self.collapsedPaths.ToggleCollapsed(path)
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) Tree() INode {
|
||||
return self.tree
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) CollapsedPaths() CollapsedPaths {
|
||||
return self.collapsedPaths
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFilter() FileTreeDisplayFilter {
|
||||
return self.filter
|
||||
}
|
||||
81
pkg/gui/filetree/file_tree_view_model_test.go
Normal file
81
pkg/gui/filetree/file_tree_view_model_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterAction(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
filter FileTreeDisplayFilter
|
||||
files []*models.File
|
||||
expected []*models.File
|
||||
}{
|
||||
{
|
||||
name: "filter files with unstaged changes",
|
||||
filter: DisplayUnstaged,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter files with staged changes",
|
||||
filter: DisplayStaged,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
|
||||
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter all files",
|
||||
filter: DisplayAll,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter conflicted files",
|
||||
filter: DisplayConflicted,
|
||||
files: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
|
||||
{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
|
||||
{Name: "dir2/file6", ShortStatus: " M", HasStagedChanges: true},
|
||||
{Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
|
||||
},
|
||||
expected: []*models.File{
|
||||
{Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
|
||||
{Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
mngr := &FileTreeViewModel{files: s.files, filter: s.filter}
|
||||
result := mngr.GetFilesForDisplay()
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type INode interface {
|
||||
IsNil() bool
|
||||
IsLeaf() bool
|
||||
GetPath() string
|
||||
GetChildren() []INode
|
||||
@@ -212,51 +211,3 @@ func getLeaves(node INode) []INode {
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func renderAux(s INode, collapsedPaths CollapsedPaths, prefix string, depth int, renderLine func(INode, int) string) []string {
|
||||
isRoot := depth == -1
|
||||
|
||||
renderLineWithPrefix := func() string {
|
||||
return prefix + renderLine(s, depth)
|
||||
}
|
||||
|
||||
if s.IsLeaf() {
|
||||
if isRoot {
|
||||
return []string{}
|
||||
}
|
||||
return []string{renderLineWithPrefix()}
|
||||
}
|
||||
|
||||
if collapsedPaths.IsCollapsed(s.GetPath()) {
|
||||
return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
|
||||
}
|
||||
|
||||
arr := []string{}
|
||||
if !isRoot {
|
||||
arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
|
||||
}
|
||||
|
||||
newPrefix := prefix
|
||||
if strings.HasSuffix(prefix, LAST_ITEM) {
|
||||
newPrefix = strings.TrimSuffix(prefix, LAST_ITEM) + NOTHING
|
||||
} else if strings.HasSuffix(prefix, INNER_ITEM) {
|
||||
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
|
||||
}
|
||||
|
||||
for i, child := range s.GetChildren() {
|
||||
isLast := i == len(s.GetChildren())-1
|
||||
|
||||
var childPrefix string
|
||||
if isRoot {
|
||||
childPrefix = newPrefix
|
||||
} else if isLast {
|
||||
childPrefix = newPrefix + LAST_ITEM
|
||||
} else {
|
||||
childPrefix = newPrefix + INNER_ITEM
|
||||
}
|
||||
|
||||
arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (gui *Gui) linesToScrollDown(view *gocui.View) int {
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollUpMain() error {
|
||||
if gui.canScrollMergePanel() {
|
||||
if gui.renderingConflicts() {
|
||||
gui.State.Panels.Merging.UserVerticalScrolling = true
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (gui *Gui) scrollUpMain() error {
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollDownMain() error {
|
||||
if gui.canScrollMergePanel() {
|
||||
if gui.renderingConflicts() {
|
||||
gui.State.Panels.Merging.UserVerticalScrolling = true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/modes/filtering"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
@@ -289,12 +291,13 @@ type guiMutexes struct {
|
||||
type guiState struct {
|
||||
// the file panels (files and commit files) can render as a tree, so we have
|
||||
// managers for them which handle rendering a flat list of files in tree form
|
||||
FileManager *filetree.FileManager
|
||||
CommitFileManager *filetree.CommitFileManager
|
||||
Submodules []*models.SubmoduleConfig
|
||||
Branches []*models.Branch
|
||||
Commits []*models.Commit
|
||||
StashEntries []*models.StashEntry
|
||||
FileTreeViewModel *filetree.FileTreeViewModel
|
||||
CommitFileTreeViewModel *filetree.CommitFileTreeViewModel
|
||||
|
||||
Submodules []*models.SubmoduleConfig
|
||||
Branches []*models.Branch
|
||||
Commits []*models.Commit
|
||||
StashEntries []*models.StashEntry
|
||||
// Suggestions will sometimes appear when typing into a prompt
|
||||
Suggestions []*types.Suggestion
|
||||
// FilteredReflogCommits are the ones that appear in the reflog panel.
|
||||
@@ -303,12 +306,14 @@ type guiState struct {
|
||||
// ReflogCommits are the ones used by the branches panel to obtain recency values
|
||||
// if we're not in filtering mode, CommitFiles and FilteredReflogCommits will be
|
||||
// one and the same
|
||||
ReflogCommits []*models.Commit
|
||||
SubCommits []*models.Commit
|
||||
Remotes []*models.Remote
|
||||
RemoteBranches []*models.RemoteBranch
|
||||
Tags []*models.Tag
|
||||
MenuItems []*menuItem
|
||||
ReflogCommits []*models.Commit
|
||||
SubCommits []*models.Commit
|
||||
Remotes []*models.Remote
|
||||
RemoteBranches []*models.RemoteBranch
|
||||
Tags []*models.Tag
|
||||
MenuItems []*menuItem
|
||||
BisectInfo *git_commands.BisectInfo
|
||||
|
||||
Updating bool
|
||||
Panels *panelStates
|
||||
SplitMainPanel bool
|
||||
@@ -388,12 +393,13 @@ func (gui *Gui) resetState(filterPath string, reuseState bool) {
|
||||
}
|
||||
|
||||
gui.State = &guiState{
|
||||
FileManager: filetree.NewFileManager(make([]*models.File, 0), gui.Log, showTree),
|
||||
CommitFileManager: filetree.NewCommitFileManager(make([]*models.CommitFile, 0), gui.Log, showTree),
|
||||
Commits: make([]*models.Commit, 0),
|
||||
FilteredReflogCommits: make([]*models.Commit, 0),
|
||||
ReflogCommits: make([]*models.Commit, 0),
|
||||
StashEntries: make([]*models.StashEntry, 0),
|
||||
FileTreeViewModel: filetree.NewFileTreeViewModel(make([]*models.File, 0), gui.Log, showTree),
|
||||
CommitFileTreeViewModel: filetree.NewCommitFileTreeViewModel(make([]*models.CommitFile, 0), gui.Log, showTree),
|
||||
Commits: make([]*models.Commit, 0),
|
||||
FilteredReflogCommits: make([]*models.Commit, 0),
|
||||
ReflogCommits: make([]*models.Commit, 0),
|
||||
StashEntries: make([]*models.StashEntry, 0),
|
||||
BisectInfo: gui.Git.Bisect.GetInfo(),
|
||||
Panels: &panelStates{
|
||||
// TODO: work out why some of these are -1 and some are 0. Last time I checked there was a good reason but I'm less certain now
|
||||
Files: &filePanelState{listPanelState{SelectedLineIdx: -1}},
|
||||
@@ -487,6 +493,7 @@ func NewGui(
|
||||
gui.PopupHandler = &RealPopupHandler{gui: gui}
|
||||
|
||||
authors.SetCustomAuthors(gui.UserConfig.Gui.AuthorColors)
|
||||
presentation.SetCustomBranches(gui.UserConfig.Gui.BranchColors)
|
||||
|
||||
return gui, nil
|
||||
}
|
||||
@@ -645,7 +652,11 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool,
|
||||
|
||||
gui.PauseBackgroundThreads = false
|
||||
|
||||
return cmdErr == nil, gui.surfaceError(cmdErr)
|
||||
if cmdErr != nil {
|
||||
return false, gui.surfaceError(cmdErr)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (gui *Gui) runSubprocess(cmdObj oscommands.ICmdObj) error { //nolint:unparam
|
||||
@@ -679,6 +690,10 @@ func (gui *Gui) loadNewRepo() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gui.OSCommand.UpdateWindowTitle(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -768,3 +783,9 @@ func (gui *Gui) setColorScheme() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) OnUIThread(f func() error) {
|
||||
gui.g.Update(func(*gocui.Gui) error {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ import (
|
||||
// original playback speed. Speed may be a decimal.
|
||||
|
||||
func Test(t *testing.T) {
|
||||
record := false
|
||||
updateSnapshots := os.Getenv("UPDATE_SNAPSHOTS") != ""
|
||||
mode := integration.GetModeFromEnv()
|
||||
speedEnv := os.Getenv("SPEED")
|
||||
includeSkipped := os.Getenv("INCLUDE_SKIPPED") != ""
|
||||
|
||||
@@ -53,8 +52,7 @@ func Test(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
},
|
||||
updateSnapshots,
|
||||
record,
|
||||
mode,
|
||||
speedEnv,
|
||||
func(t *testing.T, expected string, actual string, prefix string) {
|
||||
assert.Equal(t, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
|
||||
|
||||
@@ -908,6 +908,14 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
Handler: gui.handleOpenCommitInBrowser,
|
||||
Description: gui.Tr.LcOpenCommitInBrowser,
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
|
||||
Key: gui.getKey(config.Commits.ViewBisectOptions),
|
||||
Handler: gui.handleOpenBisectMenu,
|
||||
Description: gui.Tr.LcViewBisectOptions,
|
||||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
|
||||
@@ -1532,20 +1540,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
Handler: gui.handleSelectNextConflictHunk,
|
||||
Description: gui.Tr.SelectNextHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gocui.MouseWheelUp,
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: gui.handleSelectPrevConflictHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gocui.MouseWheelDown,
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: gui.handleSelectNextConflictHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
@@ -1578,7 +1572,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gui.getKey(config.Universal.Undo),
|
||||
Handler: gui.handlePopFileSnapshot,
|
||||
Handler: gui.handleMergeConflictUndo,
|
||||
Description: gui.Tr.LcUndo,
|
||||
},
|
||||
{
|
||||
@@ -1730,8 +1724,8 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
ViewName: "files",
|
||||
Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
|
||||
Key: gui.getKey(config.Universal.Remove),
|
||||
Handler: gui.forSubmodule(gui.handleResetRemoveSubmodule),
|
||||
Description: gui.Tr.LcViewResetAndRemoveOptions,
|
||||
Handler: gui.forSubmodule(gui.removeSubmodule),
|
||||
Description: gui.Tr.LcRemoveSubmodule,
|
||||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ func (gui *Gui) createAllViews() error {
|
||||
gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
|
||||
gui.Views.SearchPrefix.FgColor = gocui.ColorGreen
|
||||
gui.Views.SearchPrefix.Frame = false
|
||||
gui.setViewContentSync(gui.Views.SearchPrefix, SEARCH_PREFIX)
|
||||
gui.setViewContent(gui.Views.SearchPrefix, SEARCH_PREFIX)
|
||||
|
||||
gui.Views.Stash.Title = gui.Tr.StashTitle
|
||||
gui.Views.Stash.FgColor = theme.GocuiDefaultTextColor
|
||||
@@ -235,7 +235,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||
gui.Views.CommitFiles.Visible = gui.getViewNameForWindow(gui.State.Contexts.CommitFiles.GetWindowName()) == "commitFiles"
|
||||
|
||||
if gui.State.OldInformation != informationStr {
|
||||
gui.setViewContentSync(gui.Views.Information, informationStr)
|
||||
gui.setViewContent(gui.Views.Information, informationStr)
|
||||
gui.State.OldInformation = informationStr
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/lbl"
|
||||
)
|
||||
@@ -134,7 +133,7 @@ func (gui *Gui) handleMouseDrag() error {
|
||||
func (gui *Gui) getSelectedCommitFileName() string {
|
||||
idx := gui.State.Panels.CommitFiles.SelectedLineIdx
|
||||
|
||||
return gui.State.CommitFileManager.GetItemAtIndex(idx).GetPath()
|
||||
return gui.State.CommitFileTreeViewModel.GetItemAtIndex(idx).GetPath()
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMainViewForLineByLine(state *LblPanelState) error {
|
||||
@@ -172,15 +171,11 @@ func (gui *Gui) focusSelection(state *LblPanelState) error {
|
||||
|
||||
newOrigin := state.CalculateOrigin(origin, bufferHeight)
|
||||
|
||||
gui.g.Update(func(*gocui.Gui) error {
|
||||
if err := stagingView.SetOriginY(newOrigin); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stagingView.SetOriginY(newOrigin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return stagingView.SetCursor(0, selectedLineIdx-newOrigin)
|
||||
})
|
||||
|
||||
return nil
|
||||
return stagingView.SetCursor(0, selectedLineIdx-newOrigin)
|
||||
}
|
||||
|
||||
func (gui *Gui) handleToggleSelectRange() error {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (self *ListContext) FocusLine() {
|
||||
view.FocusPoint(view.OriginX(), self.GetPanelState().GetSelectedLineIdx())
|
||||
if self.RenderSelection {
|
||||
_, originY := view.Origin()
|
||||
displayStrings := self.GetDisplayStrings(originY, view.InnerHeight())
|
||||
displayStrings := self.GetDisplayStrings(originY, view.InnerHeight()+1)
|
||||
self.Gui.renderDisplayStringsAtPos(view, originY, displayStrings)
|
||||
}
|
||||
view.Footer = formatListFooter(self.GetPanelState().GetSelectedLineIdx(), self.GetItemsLength())
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
)
|
||||
@@ -33,14 +34,14 @@ func (gui *Gui) filesListContext() IListContext {
|
||||
Key: FILES_CONTEXT_KEY,
|
||||
Kind: SIDE_CONTEXT,
|
||||
},
|
||||
GetItemsLength: func() int { return gui.State.FileManager.GetItemsLength() },
|
||||
GetItemsLength: func() int { return gui.State.FileTreeViewModel.GetItemsLength() },
|
||||
OnGetPanelState: func() IListPanelState { return gui.State.Panels.Files },
|
||||
OnFocus: OnFocusWrapper(gui.onFocusFile),
|
||||
OnRenderToMain: OnFocusWrapper(gui.filesRenderToMain),
|
||||
OnClickSelectedItem: gui.handleFilePress,
|
||||
Gui: gui,
|
||||
GetDisplayStrings: func(startIdx int, length int) [][]string {
|
||||
lines := gui.State.FileManager.Render(gui.State.Modes.Diffing.Ref, gui.State.Submodules)
|
||||
lines := presentation.RenderFileTree(gui.State.FileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.State.Submodules)
|
||||
mappedLines := make([][]string, len(lines))
|
||||
for i, line := range lines {
|
||||
mappedLines[i] = []string{line}
|
||||
@@ -177,6 +178,7 @@ func (gui *Gui) branchCommitsListContext() IListContext {
|
||||
startIdx,
|
||||
length,
|
||||
gui.shouldShowGraph(),
|
||||
gui.State.BisectInfo,
|
||||
)
|
||||
},
|
||||
SelectedItem: func() (ListItem, bool) {
|
||||
@@ -218,6 +220,7 @@ func (gui *Gui) subCommitsListContext() IListContext {
|
||||
startIdx,
|
||||
length,
|
||||
gui.shouldShowGraph(),
|
||||
git_commands.NewNullBisectInfo(),
|
||||
)
|
||||
},
|
||||
SelectedItem: func() (ListItem, bool) {
|
||||
@@ -229,6 +232,10 @@ func (gui *Gui) subCommitsListContext() IListContext {
|
||||
}
|
||||
|
||||
func (gui *Gui) shouldShowGraph() bool {
|
||||
if gui.State.Modes.Filtering.Active() {
|
||||
return false
|
||||
}
|
||||
|
||||
value := gui.UserConfig.Git.Log.ShowGraph
|
||||
switch value {
|
||||
case "always":
|
||||
@@ -302,17 +309,17 @@ func (gui *Gui) commitFilesListContext() IListContext {
|
||||
Key: COMMIT_FILES_CONTEXT_KEY,
|
||||
Kind: SIDE_CONTEXT,
|
||||
},
|
||||
GetItemsLength: func() int { return gui.State.CommitFileManager.GetItemsLength() },
|
||||
GetItemsLength: func() int { return gui.State.CommitFileTreeViewModel.GetItemsLength() },
|
||||
OnGetPanelState: func() IListPanelState { return gui.State.Panels.CommitFiles },
|
||||
OnFocus: OnFocusWrapper(gui.onCommitFileFocus),
|
||||
OnRenderToMain: OnFocusWrapper(gui.commitFilesRenderToMain),
|
||||
Gui: gui,
|
||||
GetDisplayStrings: func(startIdx int, length int) [][]string {
|
||||
if gui.State.CommitFileManager.GetItemsLength() == 0 {
|
||||
if gui.State.CommitFileTreeViewModel.GetItemsLength() == 0 {
|
||||
return [][]string{{style.FgRed.Sprint("(none)")}}
|
||||
}
|
||||
|
||||
lines := gui.State.CommitFileManager.Render(gui.State.Modes.Diffing.Ref, gui.Git.Patch.PatchManager)
|
||||
lines := presentation.RenderCommitFileTree(gui.State.CommitFileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.Git.Patch.PatchManager)
|
||||
mappedLines := make([][]string, len(lines))
|
||||
for i, line := range lines {
|
||||
mappedLines[i] = []string{line}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (gui *Gui) getMenuOptions() map[string]string {
|
||||
}
|
||||
|
||||
func (gui *Gui) handleMenuClose() error {
|
||||
return gui.returnFromContextSync()
|
||||
return gui.returnFromContext()
|
||||
}
|
||||
|
||||
type createMenuOptions struct {
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"io/ioutil"
|
||||
"math"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
||||
)
|
||||
|
||||
@@ -17,7 +15,7 @@ func (gui *Gui) handleSelectPrevConflictHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectPrevConflictHunk()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,7 +23,7 @@ func (gui *Gui) handleSelectNextConflictHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectNextConflictHunk()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,7 +31,7 @@ func (gui *Gui) handleSelectNextConflict() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectNextConflict()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,42 +39,29 @@ func (gui *Gui) handleSelectPrevConflict() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectPrevConflict()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) pushFileSnapshot() error {
|
||||
content, err := gui.catSelectedFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gui.State.Panels.Merging.PushFileSnapshot(content)
|
||||
return nil
|
||||
}
|
||||
func (gui *Gui) handleMergeConflictUndo() error {
|
||||
state := gui.State.Panels.Merging
|
||||
|
||||
func (gui *Gui) handlePopFileSnapshot() error {
|
||||
prevContent, ok := gui.State.Panels.Merging.PopFileSnapshot()
|
||||
ok := state.Undo()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitFile := gui.getSelectedFile()
|
||||
if gitFile == nil {
|
||||
return nil
|
||||
}
|
||||
gui.logAction("Restoring file to previous state")
|
||||
gui.logCommand("Undoing last conflict resolution", false)
|
||||
if err := ioutil.WriteFile(gitFile.Name, []byte(prevContent), 0644); err != nil {
|
||||
if err := ioutil.WriteFile(state.GetPath(), []byte(state.GetContent()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
}
|
||||
|
||||
func (gui *Gui) handlePickHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, err := gui.resolveConflict(gui.State.Panels.Merging.Selection())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -86,19 +71,16 @@ func (gui *Gui) handlePickHunk() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if gui.State.Panels.Merging.IsFinalConflict() {
|
||||
if err := gui.handleCompleteMerge(); err != nil {
|
||||
return err
|
||||
}
|
||||
if gui.State.Panels.Merging.AllConflictsResolved() {
|
||||
return gui.onLastConflictResolved()
|
||||
}
|
||||
return gui.refreshMergePanel()
|
||||
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) handlePickAllHunks() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, err := gui.resolveConflict(mergeconflicts.ALL)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -108,17 +90,20 @@ func (gui *Gui) handlePickAllHunks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.refreshMergePanel()
|
||||
if gui.State.Panels.Merging.AllConflictsResolved() {
|
||||
return gui.onLastConflictResolved()
|
||||
}
|
||||
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error) {
|
||||
gitFile := gui.getSelectedFile()
|
||||
if gitFile == nil {
|
||||
return false, nil
|
||||
}
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, output, err := gui.State.Panels.Merging.ContentAfterConflictResolve(gitFile.Name, selection)
|
||||
state := gui.State.Panels.Merging
|
||||
|
||||
ok, content, err := state.ContentAfterConflictResolve(selection)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -127,10 +112,6 @@ func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := gui.pushFileSnapshot(); err != nil {
|
||||
return false, gui.surfaceError(err)
|
||||
}
|
||||
|
||||
var logStr string
|
||||
switch selection {
|
||||
case mergeconflicts.TOP:
|
||||
@@ -144,38 +125,31 @@ func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error
|
||||
}
|
||||
gui.logAction("Resolve merge conflict")
|
||||
gui.logCommand(logStr, false)
|
||||
return true, ioutil.WriteFile(gitFile.Name, []byte(output), 0644)
|
||||
state.PushContent(content)
|
||||
return true, ioutil.WriteFile(state.GetPath(), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMergePanelWithLock() error {
|
||||
return gui.withMergeConflictLock(gui.refreshMergePanel)
|
||||
}
|
||||
// precondition: we actually have conflicts to render
|
||||
func (gui *Gui) renderConflicts(hasFocus bool) error {
|
||||
state := gui.State.Panels.Merging.State
|
||||
content := mergeconflicts.ColoredConflictFile(state, hasFocus)
|
||||
|
||||
func (gui *Gui) refreshMergePanel() error {
|
||||
panelState := gui.State.Panels.Merging
|
||||
cat, err := gui.catSelectedFile()
|
||||
if err != nil {
|
||||
return gui.refreshMainViews(refreshMainOpts{
|
||||
main: &viewUpdateOpts{
|
||||
title: "",
|
||||
task: NewRenderStringTask(err.Error()),
|
||||
},
|
||||
if !gui.State.Panels.Merging.UserVerticalScrolling {
|
||||
// TODO: find a way to not have to do this OnUIThread thing. Why doesn't it work
|
||||
// without it given that we're calling the 'no scroll' variant below?
|
||||
gui.OnUIThread(func() error {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
if !state.Active() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gui.centerYPos(gui.Views.Main, state.GetConflictMiddle())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
panelState.SetConflictsFromCat(cat)
|
||||
|
||||
if panelState.NoConflicts() {
|
||||
return gui.handleCompleteMerge()
|
||||
}
|
||||
|
||||
hasFocus := gui.currentViewName() == "main"
|
||||
content := mergeconflicts.ColoredConflictFile(cat, panelState.State, hasFocus)
|
||||
|
||||
if err := gui.scrollToConflict(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.refreshMainViews(refreshMainOpts{
|
||||
main: &viewUpdateOpts{
|
||||
title: gui.Tr.MergeConflictsTitle,
|
||||
@@ -185,46 +159,21 @@ func (gui *Gui) refreshMergePanel() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) catSelectedFile() (string, error) {
|
||||
item := gui.getSelectedFile()
|
||||
if item == nil {
|
||||
return "", errors.New(gui.Tr.NoFilesDisplay)
|
||||
}
|
||||
|
||||
if item.Type != "file" {
|
||||
return "", errors.New(gui.Tr.NotAFile)
|
||||
}
|
||||
|
||||
cat, err := gui.Git.File.Cat(item.Name)
|
||||
if err != nil {
|
||||
gui.Log.Error(err)
|
||||
return "", err
|
||||
}
|
||||
return cat, nil
|
||||
func (gui *Gui) renderConflictsWithFocus() error {
|
||||
return gui.renderConflicts(true)
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollToConflict() error {
|
||||
if gui.State.Panels.Merging.UserVerticalScrolling {
|
||||
return nil
|
||||
}
|
||||
|
||||
panelState := gui.State.Panels.Merging
|
||||
if panelState.NoConflicts() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gui.centerYPos(gui.Views.Main, panelState.GetConflictMiddle())
|
||||
|
||||
return nil
|
||||
func (gui *Gui) renderConflictsWithLock(hasFocus bool) error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
return gui.renderConflicts(hasFocus)
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) centerYPos(view *gocui.View, y int) {
|
||||
ox, _ := view.Origin()
|
||||
_, height := view.Size()
|
||||
newOriginY := int(math.Max(0, float64(y-(height/2))))
|
||||
gui.g.Update(func(g *gocui.Gui) error {
|
||||
return view.SetOrigin(ox, newOriginY)
|
||||
})
|
||||
_ = view.SetOrigin(ox, newOriginY)
|
||||
}
|
||||
|
||||
func (gui *Gui) getMergingOptions() map[string]string {
|
||||
@@ -240,72 +189,66 @@ func (gui *Gui) getMergingOptions() map[string]string {
|
||||
}
|
||||
|
||||
func (gui *Gui) handleEscapeMerge() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
gui.State.Panels.Merging.Reset()
|
||||
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
|
||||
return err
|
||||
}
|
||||
// it's possible this method won't be called from the merging view so we need to
|
||||
// ensure we only 'return' focus if we already have it
|
||||
if gui.g.CurrentView() == gui.Views.Main {
|
||||
return gui.pushContext(gui.State.Contexts.Files)
|
||||
|
||||
return gui.escapeMerge()
|
||||
}
|
||||
|
||||
func (gui *Gui) onLastConflictResolved() error {
|
||||
// as part of refreshing files, we handle the situation where a file has had
|
||||
// its merge conflicts resolved.
|
||||
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
|
||||
}
|
||||
|
||||
func (gui *Gui) resetMergeState() {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.Reset()
|
||||
}
|
||||
|
||||
func (gui *Gui) setMergeState(path string) (bool, error) {
|
||||
content, err := gui.Git.File.Cat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
gui.State.Panels.Merging.SetContent(content, path)
|
||||
|
||||
return !gui.State.Panels.Merging.NoConflicts(), nil
|
||||
}
|
||||
|
||||
func (gui *Gui) setMergeStateWithLock(path string) (bool, error) {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
return gui.setMergeState(path)
|
||||
}
|
||||
|
||||
func (gui *Gui) resetMergeStateWithLock() {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
gui.resetMergeState()
|
||||
}
|
||||
|
||||
func (gui *Gui) escapeMerge() error {
|
||||
gui.resetMergeState()
|
||||
|
||||
// doing this in separate UI thread so that we're not still holding the lock by the time refresh the file
|
||||
gui.OnUIThread(func() error {
|
||||
return gui.pushContext(gui.State.Contexts.Files)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCompleteMerge() error {
|
||||
if err := gui.stageSelectedFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
|
||||
return err
|
||||
}
|
||||
// if we got conflicts after unstashing, we don't want to call any git
|
||||
// commands to continue rebasing/merging here
|
||||
if gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_NONE {
|
||||
return gui.handleEscapeMerge()
|
||||
}
|
||||
// if there are no more files with merge conflicts, we should ask whether the user wants to continue
|
||||
if !gui.anyFilesWithMergeConflicts() {
|
||||
return gui.promptToContinueRebase()
|
||||
}
|
||||
return gui.handleEscapeMerge()
|
||||
}
|
||||
|
||||
// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
||||
func (gui *Gui) promptToContinueRebase() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
return gui.ask(askOpts{
|
||||
title: "continue",
|
||||
prompt: gui.Tr.ConflictsResolved,
|
||||
handlersManageFocus: true,
|
||||
handleConfirm: func() error {
|
||||
if err := gui.pushContext(gui.State.Contexts.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
handleClose: func() error {
|
||||
return gui.pushContext(gui.State.Contexts.Files)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) canScrollMergePanel() bool {
|
||||
func (gui *Gui) renderingConflicts() bool {
|
||||
currentView := gui.g.CurrentView()
|
||||
if currentView != gui.Views.Main && currentView != gui.Views.Files {
|
||||
return false
|
||||
}
|
||||
|
||||
file := gui.getSelectedFile()
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.HasInlineMergeConflicts
|
||||
return gui.State.Panels.Merging.Active()
|
||||
}
|
||||
|
||||
func (gui *Gui) withMergeConflictLock(f func() error) error {
|
||||
@@ -318,3 +261,44 @@ func (gui *Gui) withMergeConflictLock(f func() error) error {
|
||||
func (gui *Gui) takeOverMergeConflictScrolling() {
|
||||
gui.State.Panels.Merging.UserVerticalScrolling = false
|
||||
}
|
||||
|
||||
func (gui *Gui) setConflictsAndRender(path string, hasFocus bool) (bool, error) {
|
||||
hasConflicts, err := gui.setMergeState(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// if we don't have conflicts we'll fall through and show the diff
|
||||
if hasConflicts {
|
||||
return true, gui.renderConflicts(hasFocus)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (gui *Gui) setConflictsAndRenderWithLock(path string, hasFocus bool) (bool, error) {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
return gui.setConflictsAndRender(path, hasFocus)
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMergeState() error {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
if gui.currentContext().GetKey() != MAIN_MERGING_CONTEXT_KEY {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasConflicts, err := gui.setConflictsAndRender(gui.State.Panels.Merging.GetPath(), true)
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
|
||||
if !hasConflicts {
|
||||
return gui.escapeMerge()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package mergeconflicts
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -53,19 +57,59 @@ func findConflicts(content string) []*mergeConflict {
|
||||
return conflicts
|
||||
}
|
||||
|
||||
var CONFLICT_START = "<<<<<<< "
|
||||
var CONFLICT_END = ">>>>>>> "
|
||||
var CONFLICT_START_BYTES = []byte(CONFLICT_START)
|
||||
var CONFLICT_END_BYTES = []byte(CONFLICT_END)
|
||||
|
||||
func determineLineType(line string) LineType {
|
||||
// TODO: find out whether we ever actually get this prefix
|
||||
trimmedLine := strings.TrimPrefix(line, "++")
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(trimmedLine, "<<<<<<< "):
|
||||
case strings.HasPrefix(trimmedLine, CONFLICT_START):
|
||||
return START
|
||||
case strings.HasPrefix(trimmedLine, "||||||| "):
|
||||
return ANCESTOR
|
||||
case trimmedLine == "=======":
|
||||
return TARGET
|
||||
case strings.HasPrefix(trimmedLine, ">>>>>>> "):
|
||||
case strings.HasPrefix(trimmedLine, CONFLICT_END):
|
||||
return END
|
||||
default:
|
||||
return NOT_A_MARKER
|
||||
}
|
||||
}
|
||||
|
||||
// tells us whether a file actually has inline merge conflicts. We need to run this
|
||||
// because git will continue showing a status of 'UU' even after the conflicts have
|
||||
// been resolved in the user's editor
|
||||
func FileHasConflictMarkers(path string) (bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
return fileHasConflictMarkersAux(file), nil
|
||||
}
|
||||
|
||||
// Efficiently scans through a file looking for merge conflict markers. Returns true if it does
|
||||
func fileHasConflictMarkersAux(file io.Reader) bool {
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// only searching for start/end markers because the others are more ambiguous
|
||||
if bytes.HasPrefix(line, CONFLICT_START_BYTES) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(line, CONFLICT_END_BYTES) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mergeconflicts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -59,3 +60,42 @@ func TestDetermineLineType(t *testing.T) {
|
||||
assert.EqualValues(t, s.expected, determineLineType(s.line))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConflictsAux(t *testing.T) {
|
||||
type scenario struct {
|
||||
content string
|
||||
expected bool
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
content: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: "blah",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: ">>>>>>> ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
content: "<<<<<<< ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
content: " <<<<<<< ",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: "a\nb\nc\n<<<<<<< ",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
reader := strings.NewReader(s.content)
|
||||
assert.EqualValues(t, s.expected, fileHasConflictMarkersAux(reader))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
func ColoredConflictFile(content string, state *State, hasFocus bool) string {
|
||||
func ColoredConflictFile(state *State, hasFocus bool) string {
|
||||
content := state.GetContent()
|
||||
if len(state.conflicts) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -3,13 +3,19 @@ package mergeconflicts
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/golang-collections/collections/stack"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
sync.Mutex
|
||||
|
||||
// path of the file with the conflicts
|
||||
path string
|
||||
|
||||
// This is a stack of the file content. It is used to undo changes.
|
||||
// The last item is the current file content.
|
||||
contents []string
|
||||
|
||||
conflicts []*mergeConflict
|
||||
// this is the index of the above `conflicts` field which is currently selected
|
||||
conflictIndex int
|
||||
@@ -17,9 +23,6 @@ type State struct {
|
||||
// this is the index of the selected conflict's available selections slice e.g. [TOP, MIDDLE, BOTTOM]
|
||||
// We use this to know which hunk of the conflict is selected.
|
||||
selectionIndex int
|
||||
|
||||
// this allows us to undo actions
|
||||
EditHistory *stack.Stack
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
@@ -28,7 +31,7 @@ func NewState() *State {
|
||||
conflictIndex: 0,
|
||||
selectionIndex: 0,
|
||||
conflicts: []*mergeConflict{},
|
||||
EditHistory: stack.New(),
|
||||
contents: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,18 +66,6 @@ func (s *State) SelectPrevConflict() {
|
||||
s.setConflictIndex(s.conflictIndex - 1)
|
||||
}
|
||||
|
||||
func (s *State) PushFileSnapshot(content string) {
|
||||
s.EditHistory.Push(content)
|
||||
}
|
||||
|
||||
func (s *State) PopFileSnapshot() (string, bool) {
|
||||
if s.EditHistory.Len() == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return s.EditHistory.Pop().(string), true
|
||||
}
|
||||
|
||||
func (s *State) currentConflict() *mergeConflict {
|
||||
if len(s.conflicts) == 0 {
|
||||
return nil
|
||||
@@ -83,8 +74,48 @@ func (s *State) currentConflict() *mergeConflict {
|
||||
return s.conflicts[s.conflictIndex]
|
||||
}
|
||||
|
||||
func (s *State) SetConflictsFromCat(cat string) {
|
||||
s.setConflicts(findConflicts(cat))
|
||||
// this is for starting a new merge conflict session
|
||||
func (s *State) SetContent(content string, path string) {
|
||||
if content == s.GetContent() && path == s.path {
|
||||
return
|
||||
}
|
||||
|
||||
s.path = path
|
||||
s.contents = []string{}
|
||||
s.PushContent(content)
|
||||
}
|
||||
|
||||
// this is for when you've resolved a conflict. This allows you to undo to a previous
|
||||
// state
|
||||
func (s *State) PushContent(content string) {
|
||||
s.contents = append(s.contents, content)
|
||||
s.setConflicts(findConflicts(content))
|
||||
}
|
||||
|
||||
func (s *State) GetContent() string {
|
||||
if len(s.contents) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.contents[len(s.contents)-1]
|
||||
}
|
||||
|
||||
func (s *State) GetPath() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *State) Undo() bool {
|
||||
if len(s.contents) <= 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
s.contents = s.contents[:len(s.contents)-1]
|
||||
|
||||
newContent := s.GetContent()
|
||||
// We could be storing the old conflicts and selected index on a stack too.
|
||||
s.setConflicts(findConflicts(newContent))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *State) setConflicts(conflicts []*mergeConflict) {
|
||||
@@ -110,29 +141,37 @@ func (s *State) availableSelections() []Selection {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) IsFinalConflict() bool {
|
||||
return len(s.conflicts) == 1
|
||||
func (s *State) AllConflictsResolved() bool {
|
||||
return len(s.conflicts) == 0
|
||||
}
|
||||
|
||||
func (s *State) Reset() {
|
||||
s.EditHistory = stack.New()
|
||||
s.contents = []string{}
|
||||
s.path = ""
|
||||
}
|
||||
|
||||
func (s *State) Active() bool {
|
||||
return s.path != ""
|
||||
}
|
||||
|
||||
func (s *State) GetConflictMiddle() int {
|
||||
return s.currentConflict().target
|
||||
currentConflict := s.currentConflict()
|
||||
|
||||
if currentConflict == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return currentConflict.target
|
||||
}
|
||||
|
||||
func (s *State) ContentAfterConflictResolve(
|
||||
path string,
|
||||
selection Selection,
|
||||
) (bool, string, error) {
|
||||
func (s *State) ContentAfterConflictResolve(selection Selection) (bool, string, error) {
|
||||
conflict := s.currentConflict()
|
||||
if conflict == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
err := utils.ForEachLineInFile(path, func(line string, i int) {
|
||||
err := utils.ForEachLineInFile(s.path, func(line string, i int) {
|
||||
if selection.isIndexToKeep(conflict, i) {
|
||||
content += line
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
)
|
||||
@@ -16,11 +18,13 @@ func (gui *Gui) modeStatuses() []modeStatus {
|
||||
{
|
||||
isActive: gui.State.Modes.Diffing.Active,
|
||||
description: func() string {
|
||||
return style.FgMagenta.Sprintf(
|
||||
"%s %s %s",
|
||||
gui.Tr.LcShowingGitDiff,
|
||||
"git diff "+gui.diffStr(),
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
return gui.withResetButton(
|
||||
fmt.Sprintf(
|
||||
"%s %s",
|
||||
gui.Tr.LcShowingGitDiff,
|
||||
"git diff "+gui.diffStr(),
|
||||
),
|
||||
style.FgMagenta,
|
||||
)
|
||||
},
|
||||
reset: gui.exitDiffMode,
|
||||
@@ -28,22 +32,20 @@ func (gui *Gui) modeStatuses() []modeStatus {
|
||||
{
|
||||
isActive: gui.Git.Patch.PatchManager.Active,
|
||||
description: func() string {
|
||||
return style.FgYellow.SetBold().Sprintf(
|
||||
"%s %s",
|
||||
gui.Tr.LcBuildingPatch,
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
)
|
||||
return gui.withResetButton(gui.Tr.LcBuildingPatch, style.FgYellow.SetBold())
|
||||
},
|
||||
reset: gui.handleResetPatch,
|
||||
},
|
||||
{
|
||||
isActive: gui.State.Modes.Filtering.Active,
|
||||
description: func() string {
|
||||
return style.FgRed.SetBold().Sprintf(
|
||||
"%s '%s' %s",
|
||||
gui.Tr.LcFilteringBy,
|
||||
gui.State.Modes.Filtering.GetPath(),
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
return gui.withResetButton(
|
||||
fmt.Sprintf(
|
||||
"%s '%s'",
|
||||
gui.Tr.LcFilteringBy,
|
||||
gui.State.Modes.Filtering.GetPath(),
|
||||
),
|
||||
style.FgRed,
|
||||
)
|
||||
},
|
||||
reset: gui.exitFilterMode,
|
||||
@@ -51,10 +53,12 @@ func (gui *Gui) modeStatuses() []modeStatus {
|
||||
{
|
||||
isActive: gui.State.Modes.CherryPicking.Active,
|
||||
description: func() string {
|
||||
return style.FgCyan.Sprintf(
|
||||
"%d commits copied %s",
|
||||
len(gui.State.Modes.CherryPicking.CherryPickedCommits),
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
return gui.withResetButton(
|
||||
fmt.Sprintf(
|
||||
"%d commits copied",
|
||||
len(gui.State.Modes.CherryPicking.CherryPickedCommits),
|
||||
),
|
||||
style.FgCyan,
|
||||
)
|
||||
},
|
||||
reset: gui.exitCherryPickingMode,
|
||||
@@ -65,13 +69,28 @@ func (gui *Gui) modeStatuses() []modeStatus {
|
||||
},
|
||||
description: func() string {
|
||||
workingTreeState := gui.Git.Status.WorkingTreeState()
|
||||
return style.FgYellow.Sprintf(
|
||||
"%s %s",
|
||||
formatWorkingTreeState(workingTreeState),
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
return gui.withResetButton(
|
||||
formatWorkingTreeState(workingTreeState), style.FgYellow,
|
||||
)
|
||||
},
|
||||
reset: gui.abortMergeOrRebaseWithConfirm,
|
||||
},
|
||||
{
|
||||
isActive: func() bool {
|
||||
return gui.State.BisectInfo.Started()
|
||||
},
|
||||
description: func() string {
|
||||
return gui.withResetButton("bisecting", style.FgGreen)
|
||||
},
|
||||
reset: gui.resetBisect,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) withResetButton(content string, textStyle style.TextStyle) string {
|
||||
return textStyle.Sprintf(
|
||||
"%s %s",
|
||||
content,
|
||||
style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (gui *Gui) refreshPatchBuildingPanel(selectedLineIdx int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
to := gui.State.CommitFileManager.GetParent()
|
||||
to := gui.State.CommitFileTreeViewModel.GetParent()
|
||||
from, reverse := gui.getFromAndReverseArgsForDiff(to)
|
||||
diff, err := gui.Git.WorkingTree.ShowFileDiff(from, to, reverse, node.GetPath(), true)
|
||||
if err != nil {
|
||||
@@ -62,6 +62,17 @@ func (gui *Gui) handleRefreshPatchBuildingPanel(selectedLineIdx int) error {
|
||||
return gui.refreshPatchBuildingPanel(selectedLineIdx)
|
||||
}
|
||||
|
||||
func (gui *Gui) onPatchBuildingFocus(selectedLineIdx int) error {
|
||||
gui.Mutexes.LineByLinePanelMutex.Lock()
|
||||
defer gui.Mutexes.LineByLinePanelMutex.Unlock()
|
||||
|
||||
if gui.State.Panels.LineByLine == nil || selectedLineIdx != -1 {
|
||||
return gui.refreshPatchBuildingPanel(selectedLineIdx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleToggleSelectionForPatch() error {
|
||||
err := gui.withLBLActiveCheck(func(state *LblPanelState) error {
|
||||
toggleFunc := gui.Git.Patch.PatchManager.AddFileLineRange
|
||||
|
||||
@@ -114,8 +114,5 @@ func getFirstRune(str string) rune {
|
||||
}
|
||||
|
||||
func SetCustomAuthors(customAuthorColors map[string]string) {
|
||||
for authorName, colorSequence := range customAuthorColors {
|
||||
style := style.New().SetFg(style.NewRGBColor(color.HEX(colorSequence, false)))
|
||||
authorStyleCache[authorName] = style
|
||||
}
|
||||
authorStyleCache = utils.SetCustomColors(customAuthorColors)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var branchPrefixColorCache = make(map[string]style.TextStyle)
|
||||
|
||||
func GetBranchListDisplayStrings(branches []*models.Branch, fullDescription bool, diffName string) [][]string {
|
||||
lines := make([][]string, len(branches))
|
||||
|
||||
@@ -43,7 +46,13 @@ func getBranchDisplayStrings(b *models.Branch, fullDescription bool, diffed bool
|
||||
|
||||
res := []string{recencyColor.Sprint(b.Recency), coloredName}
|
||||
if fullDescription {
|
||||
return append(res, style.FgYellow.Sprint(b.UpstreamName))
|
||||
return append(
|
||||
res,
|
||||
fmt.Sprintf("%s %s",
|
||||
style.FgYellow.Sprint(b.UpstreamRemote),
|
||||
style.FgYellow.Sprint(b.UpstreamBranch),
|
||||
),
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -52,6 +61,10 @@ func getBranchDisplayStrings(b *models.Branch, fullDescription bool, diffed bool
|
||||
func GetBranchTextStyle(name string) style.TextStyle {
|
||||
branchType := strings.Split(name, "/")[0]
|
||||
|
||||
if value, ok := branchPrefixColorCache[branchType]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
switch branchType {
|
||||
case "feature":
|
||||
return style.FgGreen
|
||||
@@ -78,3 +91,7 @@ func ColoredBranchStatus(branch *models.Branch) string {
|
||||
func BranchStatus(branch *models.Branch) string {
|
||||
return fmt.Sprintf("↑%s↓%s", branch.Pushables, branch.Pullables)
|
||||
}
|
||||
|
||||
func SetCustomBranches(customBranchColors map[string]string) {
|
||||
branchPrefixColorCache = utils.SetCustomColors(customBranchColors)
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
func GetCommitFileLine(name string, diffName string, commitFile *models.CommitFile, status patch.PatchStatus) string {
|
||||
var colour style.TextStyle
|
||||
if diffName == name {
|
||||
colour = theme.DiffTerminalColor
|
||||
} else {
|
||||
switch status {
|
||||
case patch.WHOLE:
|
||||
colour = style.FgGreen
|
||||
case patch.PART:
|
||||
colour = style.FgYellow
|
||||
case patch.UNSELECTED:
|
||||
colour = theme.DefaultTextColor
|
||||
}
|
||||
}
|
||||
|
||||
name = utils.EscapeSpecialChars(name)
|
||||
if commitFile == nil {
|
||||
return colour.Sprint(name)
|
||||
}
|
||||
|
||||
return getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " " + colour.Sprint(name)
|
||||
}
|
||||
|
||||
func getColorForChangeStatus(changeStatus string) style.TextStyle {
|
||||
switch changeStatus {
|
||||
case "A":
|
||||
return style.FgGreen
|
||||
case "M", "R":
|
||||
return style.FgYellow
|
||||
case "D":
|
||||
return style.FgRed
|
||||
case "C":
|
||||
return style.FgCyan
|
||||
case "T":
|
||||
return style.FgMagenta
|
||||
default:
|
||||
return theme.DefaultTextColor
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
||||
@@ -21,6 +22,11 @@ type pipeSetCacheKey struct {
|
||||
var pipeSetCache = make(map[pipeSetCacheKey][][]*graph.Pipe)
|
||||
var mutex sync.Mutex
|
||||
|
||||
type bisectBounds struct {
|
||||
newIndex int
|
||||
oldIndex int
|
||||
}
|
||||
|
||||
func GetCommitListDisplayStrings(
|
||||
commits []*models.Commit,
|
||||
fullDescription bool,
|
||||
@@ -31,6 +37,7 @@ func GetCommitListDisplayStrings(
|
||||
startIdx int,
|
||||
length int,
|
||||
showGraph bool,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) [][]string {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
@@ -39,6 +46,100 @@ func GetCommitListDisplayStrings(
|
||||
return nil
|
||||
}
|
||||
|
||||
if startIdx > len(commits) {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := utils.Min(startIdx+length, len(commits))
|
||||
// this is where my non-TODO commits begin
|
||||
rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), end)
|
||||
|
||||
filteredCommits := commits[startIdx:end]
|
||||
|
||||
bisectBounds := getbisectBounds(commits, bisectInfo)
|
||||
|
||||
// function expects to be passed the index of the commit in terms of the `commits` slice
|
||||
var getGraphLine func(int) string
|
||||
if showGraph {
|
||||
// this is where the graph begins (may be beyond the TODO commits depending on startIdx,
|
||||
// but we'll never include TODO commits as part of the graph because it'll be messy)
|
||||
graphOffset := utils.Max(startIdx, rebaseOffset)
|
||||
|
||||
pipeSets := loadPipesets(commits[rebaseOffset:])
|
||||
pipeSetOffset := utils.Max(startIdx-rebaseOffset, 0)
|
||||
graphPipeSets := pipeSets[pipeSetOffset:utils.Max(end-rebaseOffset, 0)]
|
||||
graphCommits := commits[graphOffset:end]
|
||||
graphLines := graph.RenderAux(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitSha,
|
||||
)
|
||||
getGraphLine = func(idx int) string {
|
||||
if idx >= graphOffset {
|
||||
return graphLines[idx-graphOffset]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getGraphLine = func(idx int) string { return "" }
|
||||
}
|
||||
|
||||
lines := make([][]string, 0, len(filteredCommits))
|
||||
var bisectStatus BisectStatus
|
||||
for i, commit := range filteredCommits {
|
||||
unfilteredIdx := i + startIdx
|
||||
bisectStatus = getBisectStatus(unfilteredIdx, commit.Sha, bisectInfo, bisectBounds)
|
||||
lines = append(lines, displayCommit(
|
||||
commit,
|
||||
cherryPickedCommitShaMap,
|
||||
diffName,
|
||||
parseEmoji,
|
||||
getGraphLine(unfilteredIdx),
|
||||
fullDescription,
|
||||
bisectStatus,
|
||||
bisectInfo,
|
||||
))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func getbisectBounds(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) *bisectBounds {
|
||||
if !bisectInfo.Bisecting() {
|
||||
return nil
|
||||
}
|
||||
|
||||
bisectBounds := &bisectBounds{}
|
||||
|
||||
for i, commit := range commits {
|
||||
if commit.Sha == bisectInfo.GetNewSha() {
|
||||
bisectBounds.newIndex = i
|
||||
}
|
||||
|
||||
status, ok := bisectInfo.Status(commit.Sha)
|
||||
if ok && status == git_commands.BisectStatusOld {
|
||||
bisectBounds.oldIndex = i
|
||||
return bisectBounds
|
||||
}
|
||||
}
|
||||
|
||||
// shouldn't land here
|
||||
return nil
|
||||
}
|
||||
|
||||
// precondition: slice is not empty
|
||||
func indexOfFirstNonTODOCommit(commits []*models.Commit) int {
|
||||
for i, commit := range commits {
|
||||
if !commit.IsTODO() {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// shouldn't land here
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadPipesets(commits []*models.Commit) [][]*graph.Pipe {
|
||||
// given that our cache key is a commit sha and a commit count, it's very important that we don't actually try to render pipes
|
||||
// when dealing with things like filtered commits.
|
||||
cacheKey := pipeSetCacheKey{
|
||||
@@ -57,30 +158,77 @@ func GetCommitListDisplayStrings(
|
||||
pipeSetCache[cacheKey] = pipeSets
|
||||
}
|
||||
|
||||
if startIdx > len(commits) {
|
||||
return nil
|
||||
}
|
||||
end := startIdx + length
|
||||
if end > len(commits)-1 {
|
||||
end = len(commits) - 1
|
||||
return pipeSets
|
||||
}
|
||||
|
||||
// similar to the git_commands.BisectStatus but more gui-focused
|
||||
type BisectStatus int
|
||||
|
||||
const (
|
||||
BisectStatusNone BisectStatus = iota
|
||||
BisectStatusOld
|
||||
BisectStatusNew
|
||||
BisectStatusSkipped
|
||||
// adding candidate here which isn't present in the commands package because
|
||||
// we need to actually go through the commits to get this info
|
||||
BisectStatusCandidate
|
||||
// also adding this
|
||||
BisectStatusCurrent
|
||||
)
|
||||
|
||||
func getBisectStatus(index int, commitSha string, bisectInfo *git_commands.BisectInfo, bisectBounds *bisectBounds) BisectStatus {
|
||||
if !bisectInfo.Started() {
|
||||
return BisectStatusNone
|
||||
}
|
||||
|
||||
filteredCommits := commits[startIdx : end+1]
|
||||
if bisectInfo.GetCurrentSha() == commitSha {
|
||||
return BisectStatusCurrent
|
||||
}
|
||||
|
||||
var getGraphLine func(int) string
|
||||
if showGraph {
|
||||
filteredPipeSets := pipeSets[startIdx : end+1]
|
||||
graphLines := graph.RenderAux(filteredPipeSets, filteredCommits, selectedCommitSha)
|
||||
getGraphLine = func(idx int) string { return graphLines[idx] }
|
||||
status, ok := bisectInfo.Status(commitSha)
|
||||
if ok {
|
||||
switch status {
|
||||
case git_commands.BisectStatusNew:
|
||||
return BisectStatusNew
|
||||
case git_commands.BisectStatusOld:
|
||||
return BisectStatusOld
|
||||
case git_commands.BisectStatusSkipped:
|
||||
return BisectStatusSkipped
|
||||
}
|
||||
} else {
|
||||
getGraphLine = func(idx int) string { return "" }
|
||||
if bisectBounds != nil && index >= bisectBounds.newIndex && index <= bisectBounds.oldIndex {
|
||||
return BisectStatusCandidate
|
||||
} else {
|
||||
return BisectStatusNone
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([][]string, 0, len(filteredCommits))
|
||||
for i, commit := range filteredCommits {
|
||||
lines = append(lines, displayCommit(commit, cherryPickedCommitShaMap, diffName, parseEmoji, getGraphLine(i), fullDescription))
|
||||
// should never land here
|
||||
return BisectStatusNone
|
||||
}
|
||||
|
||||
func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo) string {
|
||||
if bisectStatus == BisectStatusNone {
|
||||
return ""
|
||||
}
|
||||
return lines
|
||||
|
||||
style := getBisectStatusColor(bisectStatus)
|
||||
|
||||
switch bisectStatus {
|
||||
case BisectStatusNew:
|
||||
return style.Sprintf("<-- " + bisectInfo.NewTerm())
|
||||
case BisectStatusOld:
|
||||
return style.Sprintf("<-- " + bisectInfo.OldTerm())
|
||||
case BisectStatusCurrent:
|
||||
// TODO: i18n
|
||||
return style.Sprintf("<-- current")
|
||||
case BisectStatusSkipped:
|
||||
return style.Sprintf("<-- skipped")
|
||||
case BisectStatusCandidate:
|
||||
return style.Sprintf("?")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func displayCommit(
|
||||
@@ -90,9 +238,11 @@ func displayCommit(
|
||||
parseEmoji bool,
|
||||
graphLine string,
|
||||
fullDescription bool,
|
||||
bisectStatus BisectStatus,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) []string {
|
||||
|
||||
shaColor := getShaColor(commit, diffName, cherryPickedCommitShaMap)
|
||||
shaColor := getShaColor(commit, diffName, cherryPickedCommitShaMap, bisectStatus, bisectInfo)
|
||||
bisectString := getBisectStatusText(bisectStatus, bisectInfo)
|
||||
|
||||
actionString := ""
|
||||
if commit.Action != "" {
|
||||
@@ -122,6 +272,7 @@ func displayCommit(
|
||||
|
||||
cols := make([]string, 0, 5)
|
||||
cols = append(cols, shaColor.Sprint(commit.ShortSha()))
|
||||
cols = append(cols, bisectString)
|
||||
if fullDescription {
|
||||
cols = append(cols, style.FgBlue.Sprint(utils.UnixToDate(commit.UnixTimestamp)))
|
||||
}
|
||||
@@ -133,10 +284,39 @@ func displayCommit(
|
||||
)
|
||||
|
||||
return cols
|
||||
|
||||
}
|
||||
|
||||
func getShaColor(commit *models.Commit, diffName string, cherryPickedCommitShaMap map[string]bool) style.TextStyle {
|
||||
func getBisectStatusColor(status BisectStatus) style.TextStyle {
|
||||
switch status {
|
||||
case BisectStatusNone:
|
||||
return style.FgBlack
|
||||
case BisectStatusNew:
|
||||
return style.FgRed
|
||||
case BisectStatusOld:
|
||||
return style.FgGreen
|
||||
case BisectStatusSkipped:
|
||||
return style.FgYellow
|
||||
case BisectStatusCurrent:
|
||||
return style.FgMagenta
|
||||
case BisectStatusCandidate:
|
||||
return style.FgBlue
|
||||
}
|
||||
|
||||
// shouldn't land here
|
||||
return style.FgWhite
|
||||
}
|
||||
|
||||
func getShaColor(
|
||||
commit *models.Commit,
|
||||
diffName string,
|
||||
cherryPickedCommitShaMap map[string]bool,
|
||||
bisectStatus BisectStatus,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) style.TextStyle {
|
||||
if bisectInfo.Started() {
|
||||
return getBisectStatusColor(bisectStatus)
|
||||
}
|
||||
|
||||
diffed := commit.Sha == diffName
|
||||
shaColor := theme.DefaultTextColor
|
||||
switch commit.Status {
|
||||
|
||||
229
pkg/gui/presentation/commits_test.go
Normal file
229
pkg/gui/presentation/commits_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gookit/color"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func init() {
|
||||
color.ForceSetColorLevel(terminfo.ColorLevelNone)
|
||||
}
|
||||
|
||||
func formatExpected(expected string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(expected, "\t", ""))
|
||||
}
|
||||
|
||||
func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
commits []*models.Commit
|
||||
fullDescription bool
|
||||
cherryPickedCommitShaMap map[string]bool
|
||||
diffName string
|
||||
parseEmoji bool
|
||||
selectedCommitSha string
|
||||
startIdx int
|
||||
length int
|
||||
showGraph bool
|
||||
bisectInfo *git_commands.BisectInfo
|
||||
expected string
|
||||
focus bool
|
||||
}{
|
||||
{
|
||||
testName: "no commits",
|
||||
commits: []*models.Commit{},
|
||||
startIdx: 0,
|
||||
length: 1,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
testName: "some commits",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1"},
|
||||
{Name: "commit2", Sha: "sha2"},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha1 commit1
|
||||
sha2 commit2
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "showing graph",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha1 ⏣─╮ commit1
|
||||
sha2 ◯ │ commit2
|
||||
sha3 ◯─╯ commit3
|
||||
sha4 ◯ commit4
|
||||
sha5 ◯ commit5
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "showing graph, including rebase commits",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}, Action: "pick"},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}, Action: "pick"},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha1 pick commit1
|
||||
sha2 pick commit2
|
||||
sha3 ◯ commit3
|
||||
sha4 ◯ commit4
|
||||
sha5 ◯ commit5
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "showing graph, including rebase commits, with offset",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}, Action: "pick"},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}, Action: "pick"},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 1,
|
||||
length: 10,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha2 pick commit2
|
||||
sha3 ◯ commit3
|
||||
sha4 ◯ commit4
|
||||
sha5 ◯ commit5
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "startIdx is passed TODO commits",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}, Action: "pick"},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}, Action: "pick"},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 3,
|
||||
length: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha4 ◯ commit4
|
||||
sha5 ◯ commit5
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "only showing TODO commits",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}, Action: "pick"},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}, Action: "pick"},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha1 pick commit1
|
||||
sha2 pick commit2
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "no TODO commits, towards bottom",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 4,
|
||||
length: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha5 ◯ commit5
|
||||
`),
|
||||
},
|
||||
{
|
||||
testName: "only TODO commits except last",
|
||||
commits: []*models.Commit{
|
||||
{Name: "commit1", Sha: "sha1", Parents: []string{"sha2", "sha3"}, Action: "pick"},
|
||||
{Name: "commit2", Sha: "sha2", Parents: []string{"sha3"}, Action: "pick"},
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}, Action: "pick"},
|
||||
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}, Action: "pick"},
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
expected: formatExpected(`
|
||||
sha1 pick commit1
|
||||
sha2 pick commit2
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
focusing := false
|
||||
for _, scenario := range scenarios {
|
||||
if scenario.focus {
|
||||
focusing = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
if !focusing || s.focus {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
result := GetCommitListDisplayStrings(
|
||||
s.commits,
|
||||
s.fullDescription,
|
||||
s.cherryPickedCommitShaMap,
|
||||
s.diffName,
|
||||
s.parseEmoji,
|
||||
s.selectedCommitSha,
|
||||
s.startIdx,
|
||||
s.length,
|
||||
s.showGraph,
|
||||
s.bisectInfo,
|
||||
)
|
||||
|
||||
renderedResult := utils.RenderDisplayStrings(result)
|
||||
t.Logf("\n%s", renderedResult)
|
||||
|
||||
assert.EqualValues(t, s.expected, renderedResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,124 @@
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
func GetFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, diffName string, submoduleConfigs []*models.SubmoduleConfig, file *models.File) string {
|
||||
const EXPANDED_ARROW = "▼"
|
||||
const COLLAPSED_ARROW = "►"
|
||||
|
||||
const INNER_ITEM = "├─ "
|
||||
const LAST_ITEM = "└─ "
|
||||
const NESTED = "│ "
|
||||
const NOTHING = " "
|
||||
|
||||
func RenderFileTree(
|
||||
fileMgr *filetree.FileTreeViewModel,
|
||||
diffName string,
|
||||
submoduleConfigs []*models.SubmoduleConfig,
|
||||
) []string {
|
||||
return renderAux(fileMgr.Tree(), fileMgr.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string {
|
||||
castN := n.(*filetree.FileNode)
|
||||
return getFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File)
|
||||
})
|
||||
}
|
||||
|
||||
func RenderCommitFileTree(
|
||||
commitFileMgr *filetree.CommitFileTreeViewModel,
|
||||
diffName string,
|
||||
patchManager *patch.PatchManager,
|
||||
) []string {
|
||||
return renderAux(commitFileMgr.Tree(), commitFileMgr.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string {
|
||||
castN := n.(*filetree.CommitFileNode)
|
||||
|
||||
// This is a little convoluted because we're dealing with either a leaf or a non-leaf.
|
||||
// But this code actually applies to both. If it's a leaf, the status will just
|
||||
// be whatever status it is, but if it's a non-leaf it will determine its status
|
||||
// based on the leaves of that subtree
|
||||
var status patch.PatchStatus
|
||||
if castN.EveryFile(func(file *models.CommitFile) bool {
|
||||
return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.WHOLE
|
||||
}) {
|
||||
status = patch.WHOLE
|
||||
} else if castN.EveryFile(func(file *models.CommitFile) bool {
|
||||
return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.UNSELECTED
|
||||
}) {
|
||||
status = patch.UNSELECTED
|
||||
} else {
|
||||
status = patch.PART
|
||||
}
|
||||
|
||||
return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
|
||||
})
|
||||
}
|
||||
|
||||
func renderAux(
|
||||
s filetree.INode,
|
||||
collapsedPaths filetree.CollapsedPaths,
|
||||
prefix string,
|
||||
depth int,
|
||||
renderLine func(filetree.INode, int) string,
|
||||
) []string {
|
||||
if s == nil || s.IsNil() {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
isRoot := depth == -1
|
||||
|
||||
renderLineWithPrefix := func() string {
|
||||
return prefix + renderLine(s, depth)
|
||||
}
|
||||
|
||||
if s.IsLeaf() {
|
||||
if isRoot {
|
||||
return []string{}
|
||||
}
|
||||
return []string{renderLineWithPrefix()}
|
||||
}
|
||||
|
||||
if collapsedPaths.IsCollapsed(s.GetPath()) {
|
||||
return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
|
||||
}
|
||||
|
||||
arr := []string{}
|
||||
if !isRoot {
|
||||
arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
|
||||
}
|
||||
|
||||
newPrefix := prefix
|
||||
if strings.HasSuffix(prefix, LAST_ITEM) {
|
||||
newPrefix = strings.TrimSuffix(prefix, LAST_ITEM) + NOTHING
|
||||
} else if strings.HasSuffix(prefix, INNER_ITEM) {
|
||||
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
|
||||
}
|
||||
|
||||
for i, child := range s.GetChildren() {
|
||||
isLast := i == len(s.GetChildren())-1
|
||||
|
||||
var childPrefix string
|
||||
if isRoot {
|
||||
childPrefix = newPrefix
|
||||
} else if isLast {
|
||||
childPrefix = newPrefix + LAST_ITEM
|
||||
} else {
|
||||
childPrefix = newPrefix + INNER_ITEM
|
||||
}
|
||||
|
||||
arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, diffName string, submoduleConfigs []*models.SubmoduleConfig, file *models.File) string {
|
||||
// potentially inefficient to be instantiating these color
|
||||
// objects with each render
|
||||
partiallyModifiedColor := style.FgYellow
|
||||
@@ -51,3 +162,43 @@ func GetFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, di
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func getCommitFileLine(name string, diffName string, commitFile *models.CommitFile, status patch.PatchStatus) string {
|
||||
var colour style.TextStyle
|
||||
if diffName == name {
|
||||
colour = theme.DiffTerminalColor
|
||||
} else {
|
||||
switch status {
|
||||
case patch.WHOLE:
|
||||
colour = style.FgGreen
|
||||
case patch.PART:
|
||||
colour = style.FgYellow
|
||||
case patch.UNSELECTED:
|
||||
colour = theme.DefaultTextColor
|
||||
}
|
||||
}
|
||||
|
||||
name = utils.EscapeSpecialChars(name)
|
||||
if commitFile == nil {
|
||||
return colour.Sprint(name)
|
||||
}
|
||||
|
||||
return getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " " + colour.Sprint(name)
|
||||
}
|
||||
|
||||
func getColorForChangeStatus(changeStatus string) style.TextStyle {
|
||||
switch changeStatus {
|
||||
case "A":
|
||||
return style.FgGreen
|
||||
case "M", "R":
|
||||
return style.FgYellow
|
||||
case "D":
|
||||
return style.FgRed
|
||||
case "C":
|
||||
return style.FgCyan
|
||||
case "T":
|
||||
return style.FgMagenta
|
||||
default:
|
||||
return theme.DefaultTextColor
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user