Compare commits

..

73 Commits
v0.32 ... v0.33

Author SHA1 Message Date
Jesse Duffield
f4011643dd whoops 2022-02-05 19:14:08 +11:00
Mark Kopenga
f8b8307a29 Merge pull request #1757 from bnoctis/i18n/polish-intro
Fix & polish Chinese intro message
2022-02-03 10:21:29 +01:00
Blair Noctis
471fe313d8 fix & polish Chinese intro message
- Fix first sentence of 3), #1756
- Use 你 instead of 您 to pose a closer feel, as fellow programmers
- Polish a little so it feels more natural
2022-02-02 18:59:15 +08:00
Matt Cles
9adf4a1908 Add shared function for loading map of custom colors 2022-02-01 18:55:45 +11:00
Matt Cles
4df7646654 Add configurable colors for branch prefixes
Branches can now be colored based on their prefix, if it matches
a user defined prefix in the config file. If no user defined
prefix matches, then it will fallback to the defaults: green for
'feature', yellow for 'bugfix', and red for 'hotfix'. All
remaining branches will be set to the default text color.
2022-02-01 18:55:45 +11:00
Mark Kopenga
c7c4a375a9 Merge pull request #1750 from mark2185/fix-issue-template
'git-rev parse' should be 'git rev-parse'
2022-01-31 19:24:20 +01:00
Luka Markušić
a2fd3541d5 'git-rev parse' should be 'git rev-parse' 2022-01-31 18:07:35 +01:00
Jing Mi
15ca38ba2e Update README.md
Use `go install` instead of `go get` to install as `$GOPATH/bin/lazygit`, since using `go get` to install binary is deprecated
2022-01-31 08:59:55 +11:00
Jesse Duffield
e0ae134ee4 generate snapshot for expected dir in separate tmp dir 2022-01-29 00:17:32 +11:00
Jesse Duffield
1d90e1b565 add submodule integration tests 2022-01-29 00:17:32 +11:00
Jesse Duffield
1b09674ce8 simplify submodule remove 2022-01-29 00:17:32 +11:00
Jesse Duffield
d13a648132 ensure stash panel refreshes 2022-01-28 20:07:30 +11:00
Jesse Duffield
bed185eb28 stop retrying due to index lock for now 2022-01-27 21:25:04 +11:00
Jesse Duffield
84a1992055 better locking of merge panel state 2022-01-27 21:25:04 +11:00
Jesse Duffield
7f85bf5563 Update CONTRIBUTING.md 2022-01-27 19:32:30 +11:00
Jesse Duffield
3e21143a0e add debugging section to contributor guide 2022-01-27 19:30:25 +11:00
Jesse Duffield
fa2e7ae1e7 show only merge conflict files when there are merge conflicts 2022-01-26 20:28:32 +11:00
Jesse Duffield
5a3f81d1f7 select current bisect commit even if bisect was started on another branch 2022-01-26 19:29:17 +11:00
Jesse Duffield
ebbdf829e7 fix panic on rebase 2022-01-26 17:20:58 +11:00
Jesse Duffield
5e6e1617aa add another bisect integration test 2022-01-26 16:52:20 +11:00
Jesse Duffield
5e9cfab283 better rendering of bisect markets in commits panel 2022-01-26 16:52:20 +11:00
Jesse Duffield
ca7cfc3232 only show commits from start ref if bad commit is reachable from there 2022-01-26 16:52:20 +11:00
Jesse Duffield
dc765c4166 add a file close that was missed 2022-01-26 14:50:47 +11:00
Jesse Duffield
c8cc18920f improve merge conflict flow 2022-01-26 14:50:47 +11:00
Jesse Duffield
ce3bcfe37c fix reflog failing to properly refresh 2022-01-26 10:58:33 +11:00
Jesse Duffield
f4ddf2f0d4 redo commit revert integration test 2022-01-26 09:23:55 +11:00
Jesse Duffield
54b1bc31cd allow running integration tests at original speed 2022-01-26 09:23:55 +11:00
glendsoza
eb57e3ead0 Fixed the issue with linting 2022-01-26 09:04:12 +11:00
glendsoza
0caa391c4d Changes as per review 2022-01-26 09:04:12 +11:00
glendsoza
0c6bdac2f7 Changes as per review 2022-01-26 09:04:12 +11:00
glendsoza
257e222f8d ISSUE 1706: Ask confirmation before reverting a commit 2022-01-26 09:04:12 +11:00
Mikael Elkiaer
874e230aef run go fmt 2022-01-25 23:23:55 +11:00
Mikael Elkiaer
4da5795ef1 fixed indentation by swapping spaces for tabs 2022-01-25 23:23:55 +11:00
Mikael Elkiaer
03c9acad26 add tests specific for URL escaping in PRs 2022-01-25 23:23:55 +11:00
Mikael Elkiaer
d53322675d update unit tests not expecting url escaping 2022-01-25 23:23:55 +11:00
Mikael Elkiaer
ae18ad5b66 add URL encoding in pull request branch names 2022-01-25 23:23:55 +11:00
MATSUDA Takashi
b70075eba6 go mod vendor 2022-01-25 22:54:09 +11:00
MATSUDA Takashi
e413c216ba go get github.com/gdamore/tcell/v2@66f061b1 2022-01-25 22:54:09 +11:00
Jesse Duffield
14b9a0b647 stop skipping stash warnings 2022-01-24 19:18:09 +11:00
Jesse Duffield
58bdcbf1dd always refresh after stash action 2022-01-24 19:18:09 +11:00
Jesse Duffield
88d685df53 better bisect script 2022-01-23 14:41:48 +11:00
Jesse Duffield
61ccc1efd2 exclude interactive rebase TODO commits from commit graph 2022-01-22 15:12:24 +11:00
Jesse Duffield
5b7dd9e43c properly resolve cyclic dependency 2022-01-22 10:48:51 +11:00
Jesse Duffield
4ab5e54139 add support for git bisect 2022-01-22 10:48:51 +11:00
Birger Skogeng Pedersen
ab84410b41 check returned error (if any) from UpdateWindowTitle 2022-01-21 23:13:39 +11:00
Birger Skogeng Pedersen
a78cbf4882 remove redundant title-setting shell command args 2022-01-21 23:13:39 +11:00
Birger Skogeng Pedersen
62a7d9bbcc invoke title-setting shell command appropriately 2022-01-21 23:13:39 +11:00
Birger Skogeng Pedersen
555d8bbc96 set repo name as window title when loading repo, fix #1691 2022-01-21 23:13:39 +11:00
bin101
ad23bd03a0 fix: custom service usage 2022-01-21 23:13:00 +11:00
Jesse Duffield
1f923bdc4b softer auto-generation message 2022-01-19 21:40:50 +11:00
Jesse Duffield
b5a8ecf786 update contributing docs 2022-01-18 22:06:17 +11:00
Jesse Duffield
3e80a9e886 refactor to group up more commonly used git command stuff 2022-01-18 22:01:09 +11:00
Jesse Duffield
9706416a41 the gods will judge me 2022-01-18 21:42:23 +11:00
Jesse Duffield
56f2ecb06c another integration test 2022-01-18 21:25:52 +11:00
Jesse Duffield
d7c79ba20b fix integration test which was actually asserting incorrect behaviour 2022-01-18 21:25:52 +11:00
Jesse Duffield
b6fb7f1365 fix integration test 2022-01-18 21:25:52 +11:00
Jesse Duffield
dbb8b17d83 add integration test for deleting a range of lines in the staging panel 2022-01-18 21:25:52 +11:00
Jesse Duffield
d019626342 do not show branch graph when in filtering mode 2022-01-17 22:00:53 +11:00
Jesse Duffield
595aca2a4b make integration test pass 2022-01-17 19:14:59 +11:00
Jesse Duffield
2691477aff allow sandbox mode with integration tests 2022-01-17 19:14:59 +11:00
Jesse Duffield
8ca71eeb36 add git bisect run script 2022-01-17 19:14:59 +11:00
Jesse Duffield
d3a3c8d87d add integration test for merge conflicts resolved externally 2022-01-17 19:14:59 +11:00
Jesse Duffield
ee622d044e add integration test for staging view 2022-01-17 19:14:59 +11:00
Jesse Duffield
99035959a1 fix merge scroll bug 2022-01-16 23:16:05 +11:00
Jesse Duffield
0092c9d08d fix bug with subprocess 2022-01-16 03:32:09 +00:00
Jesse Duffield
befa35645e fix bug which prevented quitting with confirm 2022-01-15 20:35:25 +11:00
Jesse Duffield
7a690f9078 appease CI 2022-01-15 15:34:01 +11:00
Jesse Duffield
dafac52a4c see if this fixes CI linting 2022-01-15 15:34:01 +11:00
Jesse Duffield
1c84f77319 always specify upstream when pushing/pulling 2022-01-15 15:34:01 +11:00
Jesse Duffield
8d8bdb948b avoid deadlock in merge panel 2022-01-15 14:15:41 +11:00
Jesse Duffield
cdcfeb396f stop refreshing the screen so much 2022-01-15 14:15:41 +11:00
Jesse Duffield
f5b9ad8c00 add complex custom command integration test 2022-01-15 10:14:19 +11:00
Jesse Duffield
8263d15b03 fix issue where custom command would not open a menu 2022-01-15 10:14:19 +11:00
1090 changed files with 8075 additions and 1924 deletions

View File

@@ -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.

View File

@@ -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
View File

@@ -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/

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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
![border example](../../assets/colored-border-example.png)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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 {

View 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
}

View 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
}

View File

@@ -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 {

View File

@@ -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()
})

View File

@@ -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

View 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,
}
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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()
})
}
}

View File

@@ -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,

View File

@@ -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

View 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()
})
}
}

View File

@@ -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

View File

@@ -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 != ""
}

View File

@@ -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)
}

View File

@@ -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())
}

View File

@@ -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()
}

View File

@@ -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",

View File

@@ -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
View 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
}
}
}
}

View File

@@ -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}})
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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()
},
})
}
}

View File

@@ -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())
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View 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.

View File

@@ -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)
})
}

View File

@@ -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))

View 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
}

View File

@@ -1,9 +0,0 @@
package filetree
const EXPANDED_ARROW = "▼"
const COLLAPSED_ARROW = "►"
const INNER_ITEM = "├─ "
const LAST_ITEM = "└─ "
const NESTED = "│ "
const NOTHING = " "

View File

@@ -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)
})
}

View 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)
})
}
}

View File

@@ -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)
})
}

View 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))
})
}
}

View 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
}

View 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)
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
})
}

View File

@@ -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))

View File

@@ -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,
},
{

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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())

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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),
)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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 {

View 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)
})
}
}
}

View File

@@ -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