mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 13:37:09 -04:00
Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936ae69429 | ||
|
|
16802a048e | ||
|
|
c0e805718d | ||
|
|
35c5f940a4 | ||
|
|
77093451d4 | ||
|
|
368d6437b8 | ||
|
|
01f0efb997 | ||
|
|
d0851113d1 | ||
|
|
df58c75ca4 | ||
|
|
d8c7d47067 | ||
|
|
f79a8c281f | ||
|
|
996a1e469f | ||
|
|
9d5d61260a | ||
|
|
18db5eafd4 | ||
|
|
2183c157d4 | ||
|
|
5dec080719 | ||
|
|
fc38e3b54d | ||
|
|
93d845cb01 | ||
|
|
67fb28e2b8 | ||
|
|
679b0456f3 | ||
|
|
f7f24dbfc1 | ||
|
|
b942df02a1 | ||
|
|
f12a2edefa | ||
|
|
7e54b5641f | ||
|
|
89a09be6a0 | ||
|
|
069af50f50 | ||
|
|
c6929c36ae | ||
|
|
e8f4508cba | ||
|
|
b8d33b8f7b | ||
|
|
946b8b5670 | ||
|
|
d479a41cad | ||
|
|
7149cfeb11 | ||
|
|
265cdde7bc | ||
|
|
e87fc4a229 | ||
|
|
b45c5d8491 | ||
|
|
f6f82091bc | ||
|
|
48df9b7f4e | ||
|
|
fd86d29400 | ||
|
|
a11e91e651 | ||
|
|
6127e487dd | ||
|
|
1d89a5daa1 | ||
|
|
f4ccb68464 | ||
|
|
b90af5461a | ||
|
|
acbcf9933d | ||
|
|
21f8857d36 | ||
|
|
965f7bfcb2 | ||
|
|
c769a78db5 | ||
|
|
1cf24a02d3 | ||
|
|
657b1e897f | ||
|
|
00b922604a | ||
|
|
1bb138c79c | ||
|
|
c8fc1c3f5a | ||
|
|
c5ea80fa67 | ||
|
|
d98130c3ef | ||
|
|
7c5f33980f | ||
|
|
cceff63823 | ||
|
|
5c42e1a5dc | ||
|
|
6c3671f807 | ||
|
|
89ba3a38b4 | ||
|
|
6f709456fe | ||
|
|
277ca706eb | ||
|
|
8a1c763942 | ||
|
|
31bdd27e88 | ||
|
|
e00f248cf7 | ||
|
|
cd9111837e | ||
|
|
41222f07ed | ||
|
|
ae780fdb81 | ||
|
|
a05bdc3ee4 | ||
|
|
1da0427e3a | ||
|
|
af5b3be286 | ||
|
|
81281a49b2 | ||
|
|
ff8823093c | ||
|
|
0300bfdec2 | ||
|
|
f770a6246b | ||
|
|
5e9a897348 | ||
|
|
f2d0f362d4 | ||
|
|
ae07cf5506 | ||
|
|
f3fa9ec2d1 | ||
|
|
e661916ba6 | ||
|
|
0a8731eecf | ||
|
|
14a974742f | ||
|
|
534703a023 | ||
|
|
9fef4447b6 | ||
|
|
7aa843c75a | ||
|
|
a27092a7ad | ||
|
|
a3450dfdfc | ||
|
|
b4e9806352 | ||
|
|
f495945b87 | ||
|
|
47de61b57c | ||
|
|
06c878c051 | ||
|
|
ed93e0a2b0 | ||
|
|
c5050ecabd | ||
|
|
78b495f50a | ||
|
|
53e06b71ae | ||
|
|
b166b8f776 | ||
|
|
c5c9f5bb94 | ||
|
|
09e80e5f2a | ||
|
|
be30cbb375 | ||
|
|
b64f55518b | ||
|
|
926ed7b9b2 | ||
|
|
8052ac4fd6 | ||
|
|
c976839a63 | ||
|
|
ac127f017e | ||
|
|
17140e1d8d | ||
|
|
cd418ec929 | ||
|
|
8c89069965 | ||
|
|
09db4c4397 | ||
|
|
96310288ee | ||
|
|
c841ba8237 | ||
|
|
9a6f21ce42 | ||
|
|
fa0414777f | ||
|
|
5d2584a188 | ||
|
|
fb15a2f4f8 | ||
|
|
ef62a35d79 | ||
|
|
05425cfba0 | ||
|
|
b623ecf898 | ||
|
|
aedfce2845 | ||
|
|
c19f52255c | ||
|
|
fa97b0c76e | ||
|
|
588850b090 | ||
|
|
13639ac924 | ||
|
|
5c11b1ecb7 | ||
|
|
7c7f7bf9b9 | ||
|
|
57a1817deb | ||
|
|
b6c73b3620 | ||
|
|
7bdba1abe4 | ||
|
|
c77df59b9b | ||
|
|
f910b42c9c | ||
|
|
dde70486a1 | ||
|
|
186b7197e4 | ||
|
|
6ec88ce8ba | ||
|
|
e3c6738535 | ||
|
|
bc4ace8357 | ||
|
|
b40190bd94 | ||
|
|
abbd598992 | ||
|
|
5679efe174 | ||
|
|
6bf28d325f | ||
|
|
43b5a80738 | ||
|
|
b13cfdfea0 | ||
|
|
b647241521 | ||
|
|
1e85bc3d62 | ||
|
|
3a1921cab0 | ||
|
|
6386a03805 | ||
|
|
d69b2fef9a | ||
|
|
50b0d85cd3 | ||
|
|
bfcbf228bf | ||
|
|
87e0f6b92d | ||
|
|
d0499286e2 | ||
|
|
0af63daf18 | ||
|
|
8b894d7bf5 | ||
|
|
f7449ed53a | ||
|
|
ff25016a6a | ||
|
|
65d6d7fb2d | ||
|
|
1a1f042f49 | ||
|
|
5b3f684afb | ||
|
|
618b845f5e | ||
|
|
f96246c038 | ||
|
|
773eb0310f | ||
|
|
03ce22f3c9 | ||
|
|
b3d086bdc1 | ||
|
|
cf048e4807 | ||
|
|
a6ebc5869e | ||
|
|
ffc2a6805a | ||
|
|
d24feb14e5 | ||
|
|
245563bc99 | ||
|
|
37997dcbcd | ||
|
|
924a152ae9 | ||
|
|
2b8e412ed3 |
@@ -0,0 +1,89 @@
|
||||
name: Build PR Image
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
jobs:
|
||||
build-application:
|
||||
name: Build and Push `lazygit`
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }}
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Generate UUID image name
|
||||
id: uuid
|
||||
run: echo "UUID_APP_TAG=$(uuidgen)" >> $GITHUB_ENV
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: registry.uffizzi.com/${{ env.UUID_APP_TAG }}
|
||||
tags: type=raw,value=60d
|
||||
- name: Build and Push Image to registry.uffizzi.com ephemeral registry
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: ./
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: ./uffizzi/DockerfileTtyd
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
render-compose-file:
|
||||
name: Render Docker Compose File
|
||||
# Pass output of this workflow to another triggered by `workflow_run` event.
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-application
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Render Compose File
|
||||
run: |
|
||||
APP_IMAGE=$(echo ${{ needs.build-application.outputs.tags }})
|
||||
export APP_IMAGE
|
||||
# Render simple template from environment variables.
|
||||
envsubst < ./uffizzi/docker-compose.uffizzi.yml > docker-compose.rendered.yml
|
||||
cat docker-compose.rendered.yml
|
||||
- name: Upload Rendered Compose File as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: docker-compose.rendered.yml
|
||||
retention-days: 2
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
|
||||
delete-preview:
|
||||
name: Call for Preview Deletion
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
steps:
|
||||
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
|
||||
- name: Serialize PR Event to File
|
||||
run: echo '${{ toJSON(github.event) }}' > event.json
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Deploy Uffizzi Preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build PR Image"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
jobs:
|
||||
cache-compose-file:
|
||||
name: Cache Compose File
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }}
|
||||
pr-number: ${{ env.PR_NUMBER }}
|
||||
steps:
|
||||
- name: 'Download artifacts'
|
||||
# Fetch output (zip archive) from the workflow run that triggered this workflow.
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "preview-spec"
|
||||
})[0];
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let fs = require('fs');
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
|
||||
- name: 'Unzip artifact'
|
||||
run: unzip preview-spec.zip
|
||||
- name: Read Event into ENV
|
||||
run: |
|
||||
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
|
||||
cat event.json >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Hash Rendered Compose File
|
||||
id: hash
|
||||
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
|
||||
- name: Cache Rendered Compose File
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: docker-compose.rendered.yml
|
||||
key: ${{ env.COMPOSE_FILE_HASH }}
|
||||
|
||||
- name: Read PR Number From Event Object
|
||||
id: pr
|
||||
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
|
||||
|
||||
- name: DEBUG - Print Job Outputs
|
||||
if: ${{ runner.debug }}
|
||||
run: |
|
||||
echo "PR number: ${{ env.PR_NUMBER }}"
|
||||
echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}"
|
||||
cat event.json
|
||||
deploy-uffizzi-preview:
|
||||
name: Use Remote Workflow to Preview on Uffizzi
|
||||
needs:
|
||||
- cache-compose-file
|
||||
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2.6.1
|
||||
with:
|
||||
# If this workflow was triggered by a PR close event, cache-key will be an empty string
|
||||
# and this reusable workflow will delete the preview deployment.
|
||||
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
|
||||
compose-file-cache-path: docker-compose.rendered.yml
|
||||
server: https://app.uffizzi.com
|
||||
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
+1
-5
@@ -42,11 +42,7 @@ test/integration/*/used_config/
|
||||
test/integration/*/expected/**/hooks/
|
||||
test/integration/*/expected_remote/**/hooks/
|
||||
|
||||
test/integration_new/**/actual/
|
||||
test/integration_new/**/used_config/
|
||||
# these sample hooks waste too much space
|
||||
test/integration_new/**/expected/**/hooks/
|
||||
test/integration_new/**/expected_remote/**/hooks/
|
||||
test/integration_new/**
|
||||
|
||||
oryxBuildBinary
|
||||
__debug_bin
|
||||
|
||||
Vendored
+21
@@ -29,6 +29,9 @@
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"focus": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -38,6 +41,9 @@
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
},
|
||||
"presentation": {
|
||||
"focus": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,6 +53,21 @@
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
},
|
||||
"presentation": {
|
||||
"focus": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Open deprecated test TUI",
|
||||
"type": "shell",
|
||||
"command": "go run pkg/integration/deprecated/cmd/tui/main.go",
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
},
|
||||
"presentation": {
|
||||
"focus": true
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+3
-1
@@ -37,10 +37,12 @@ See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more
|
||||
|
||||
## Running in a Github Codespace
|
||||
|
||||
If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace:
|
||||
If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace:
|
||||
|
||||

|
||||
|
||||
To run lazygit from within the integrated terminal just go `go run main.go`
|
||||
|
||||
This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
+10
-2
@@ -21,6 +21,7 @@ If you want to change the config directory:
|
||||
```yaml
|
||||
gui:
|
||||
# stuff relating to the UI
|
||||
windowSize: 'normal' # one of 'normal' | 'half' | 'full' default is 'normal'
|
||||
scrollHeight: 2 # how many lines you scroll by
|
||||
scrollPastBottom: true # enable scrolling past the bottom
|
||||
sidePanelWidth: 0.3333 # number from 0 to 1
|
||||
@@ -46,6 +47,8 @@ gui:
|
||||
- blue
|
||||
unstagedChangesColor:
|
||||
- red
|
||||
defaultFgColor:
|
||||
- default
|
||||
commitLength:
|
||||
show: true
|
||||
mouseEvents: true
|
||||
@@ -59,19 +62,21 @@ gui:
|
||||
showIcons: false
|
||||
commandLogSize: 8
|
||||
splitDiff: 'auto' # one of 'auto' | 'always'
|
||||
skipRewordInEditorWarning: false # for skipping the confirmation before launching the reword editor
|
||||
git:
|
||||
paging:
|
||||
colorArg: always
|
||||
useConfig: false
|
||||
commit:
|
||||
signOff: false
|
||||
verbose: default # one of 'default' | 'always' | 'never'
|
||||
merging:
|
||||
# only applicable to unix users
|
||||
manualCommit: false
|
||||
# extra args passed to `git merge`, e.g. --no-ff
|
||||
args: ''
|
||||
log:
|
||||
# one of date-order, author-date-order, topo-order.
|
||||
# one of date-order, author-date-order, topo-order or default.
|
||||
# topo-order makes it easier to read the git log graph, but commits may not
|
||||
# appear chronologically. See https://git-scm.com/docs/git-log#_commit_ordering
|
||||
order: 'topo-order'
|
||||
@@ -99,7 +104,6 @@ refresher:
|
||||
update:
|
||||
method: prompt # can be: prompt | background | never
|
||||
days: 14 # how often an update is checked for
|
||||
reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined'
|
||||
confirmOnQuit: false
|
||||
# determines whether hitting 'esc' will quit the application when there is nothing to cancel/close
|
||||
quitOnTopLevelReturn: false
|
||||
@@ -111,6 +115,8 @@ keybinding:
|
||||
quit: 'q'
|
||||
quit-alt1: '<c-c>' # alternative/alias of quit
|
||||
return: '<esc>' # return to previous menu, will quit if there's nowhere to return
|
||||
# When set to a printable character, this will work for returning from non-prompt panels
|
||||
return-alt1: null
|
||||
quitWithoutChangingDirectory: 'Q'
|
||||
togglePanel: '<tab>' # goto the next panel
|
||||
prevItem: '<up>' # go one line up
|
||||
@@ -185,6 +191,8 @@ keybinding:
|
||||
viewResetOptions: 'D'
|
||||
fetch: 'f'
|
||||
toggleTreeView: '`'
|
||||
openMergeTool: 'M'
|
||||
openStatusFilter: '<c-b>'
|
||||
branches:
|
||||
createPullRequest: 'o'
|
||||
viewPullRequestOptions: 'O'
|
||||
|
||||
@@ -191,6 +191,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: toggle line staged / unstaged
|
||||
<kbd>d</kbd>: delete change (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: commit changes
|
||||
<kbd>w</kbd>: commit changes without pre-commit hook
|
||||
<kbd>C</kbd>: commit changes using git editor
|
||||
</pre>
|
||||
|
||||
## Reflog
|
||||
|
||||
@@ -251,6 +251,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: 選択行をステージ/アンステージ
|
||||
<kbd>d</kbd>: 変更を削除 (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: 変更をコミット
|
||||
<kbd>w</kbd>: pre-commitフックを実行せずに変更をコミット
|
||||
<kbd>C</kbd>: gitエディタを使用して変更をコミット
|
||||
</pre>
|
||||
|
||||
## リモート
|
||||
|
||||
@@ -136,6 +136,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: 선택한 행을 staged / unstaged
|
||||
<kbd>d</kbd>: 변경을 삭제 (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: 커밋 변경내용
|
||||
<kbd>w</kbd>: commit changes without pre-commit hook
|
||||
<kbd>C</kbd>: Git 편집기를 사용하여 변경 내용을 커밋합니다.
|
||||
</pre>
|
||||
|
||||
## 브랜치
|
||||
|
||||
@@ -229,6 +229,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: toggle lijnen staged / unstaged
|
||||
<kbd>d</kbd>: verwijdert change (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: commit veranderingen
|
||||
<kbd>w</kbd>: commit veranderingen zonder pre-commit hook
|
||||
<kbd>C</kbd>: commit veranderingen met de git editor
|
||||
</pre>
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -168,6 +168,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: toggle line staged / unstaged
|
||||
<kbd>d</kbd>: delete change (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: Zatwierdź zmiany
|
||||
<kbd>w</kbd>: zatwierdź zmiany bez skryptu pre-commit
|
||||
<kbd>C</kbd>: Zatwierdź zmiany używając edytora
|
||||
</pre>
|
||||
|
||||
## Reflog
|
||||
|
||||
@@ -238,6 +238,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
<kbd>space</kbd>: 切换行暂存状态
|
||||
<kbd>d</kbd>: 取消变更 (git reset)
|
||||
<kbd>E</kbd>: edit hunk
|
||||
<kbd>c</kbd>: 提交更改
|
||||
<kbd>w</kbd>: 提交更改而无需预先提交钩子
|
||||
<kbd>C</kbd>: 提交更改(使用编辑器编辑提交信息)
|
||||
</pre>
|
||||
|
||||
## 正常
|
||||
|
||||
@@ -11,14 +11,14 @@ require (
|
||||
github.com/creack/pty v1.1.11
|
||||
github.com/fsmiamoto/git-todo-parser v0.0.2
|
||||
github.com/fsnotify/fsnotify v1.4.7
|
||||
github.com/gdamore/tcell/v2 v2.5.3
|
||||
github.com/gdamore/tcell/v2 v2.5.4
|
||||
github.com/go-errors/errors v1.4.2
|
||||
github.com/gookit/color v1.4.2
|
||||
github.com/imdario/mergo v0.3.11
|
||||
github.com/integrii/flaggy v1.4.0
|
||||
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68
|
||||
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20221016041636-16c24668f71c
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20230105224502-214a0df08da7
|
||||
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
|
||||
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
|
||||
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
|
||||
@@ -62,14 +62,14 @@ require (
|
||||
github.com/onsi/ginkgo v1.10.3 // indirect
|
||||
github.com/onsi/gomega v1.7.1 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||
github.com/rivo/uniseg v0.4.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.3 // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.2.1 // indirect
|
||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
|
||||
golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8 // indirect
|
||||
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
|
||||
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 // indirect
|
||||
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/term v0.3.0 // indirect
|
||||
golang.org/x/text v0.5.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
@@ -35,8 +35,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
|
||||
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
|
||||
github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
|
||||
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
|
||||
github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
|
||||
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=
|
||||
@@ -72,8 +72,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T
|
||||
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
|
||||
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE=
|
||||
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20221016041636-16c24668f71c h1:ttqCvM86hyp4V3DtRPxLo9imD1s+n6ry/zshPx0tt98=
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20221016041636-16c24668f71c/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20230105224502-214a0df08da7 h1:a7T87NGCrmFhzWBWvaaPQppjbY9/w0Vl4hoB7RnFaug=
|
||||
github.com/jesseduffield/gocui v0.3.1-0.20230105224502-214a0df08da7/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
|
||||
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
|
||||
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
|
||||
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
|
||||
@@ -114,7 +114,6 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw=
|
||||
@@ -137,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
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=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
|
||||
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
|
||||
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM=
|
||||
@@ -171,20 +170,25 @@ github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70
|
||||
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
|
||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8 h1:s/+U+w0teGzcoH2mdIlFQ6KfVKGaYpgyGdUefZrn9TU=
|
||||
golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c h1:dk0ukUIHmGHqASjP0iue2261isepFCC6XRCSd1nHgDw=
|
||||
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c/go.mod h1:iQL9McJNjoIa5mjH6nYTCTZXUN6RP+XW3eib7Ya3XcI=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -194,23 +198,30 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 h1:OK7RB6t2WQX54srQQYSXMW8dF5C6/8+oA/s5QBmmto4=
|
||||
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
|
||||
golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+12
-37
@@ -7,8 +7,6 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -17,7 +15,6 @@ import (
|
||||
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
|
||||
"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/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
@@ -101,54 +98,36 @@ func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
|
||||
return app, err
|
||||
}
|
||||
|
||||
gitVersion, err := app.validateGitVersion()
|
||||
if err != nil {
|
||||
return app, err
|
||||
}
|
||||
|
||||
showRecentRepos, err := app.setupRepo()
|
||||
if err != nil {
|
||||
return app, err
|
||||
}
|
||||
|
||||
gitConfig := git_config.NewStdCachedGitConfig(app.Log)
|
||||
|
||||
app.Gui, err = gui.NewGui(common, config, gitConfig, app.Updater, showRecentRepos, dirName)
|
||||
app.Gui, err = gui.NewGui(common, config, gitVersion, app.Updater, showRecentRepos, dirName)
|
||||
if err != nil {
|
||||
return app, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (app *App) validateGitVersion() error {
|
||||
output, err := app.OSCommand.Cmd.New("git --version").RunWithOutput()
|
||||
func (app *App) validateGitVersion() (*git_commands.GitVersion, error) {
|
||||
version, err := git_commands.GetGitVersion(app.OSCommand)
|
||||
// if we get an error anywhere here we'll show the same status
|
||||
minVersionError := errors.New(app.Tr.MinGitVersionError)
|
||||
if err != nil {
|
||||
return minVersionError
|
||||
return nil, minVersionError
|
||||
}
|
||||
|
||||
if isGitVersionValid(output) {
|
||||
return nil
|
||||
if version.IsOlderThan(2, 0, 0) {
|
||||
return nil, minVersionError
|
||||
}
|
||||
|
||||
return minVersionError
|
||||
}
|
||||
|
||||
func isGitVersionValid(versionStr string) bool {
|
||||
// output should be something like: 'git version 2.23.0 (blah)'
|
||||
re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
|
||||
matches := re.FindStringSubmatch(versionStr)
|
||||
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
gitVersion := matches[1]
|
||||
majorVersion, err := strconv.Atoi(gitVersion[0:1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if majorVersion < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func isDirectoryAGitRepository(dir string) (bool, error) {
|
||||
@@ -169,10 +148,6 @@ func openRecentRepo(app *App) bool {
|
||||
}
|
||||
|
||||
func (app *App) setupRepo() (bool, error) {
|
||||
if err := app.validateGitVersion(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if env.GetGitDirEnv() != "" {
|
||||
// we've been given the git dir directly. We'll verify this dir when initializing our Git object
|
||||
return false, nil
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsGitVersionValid(t *testing.T) {
|
||||
type scenario struct {
|
||||
versionStr string
|
||||
expectedResult bool
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"git version 1.9.0",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"git version 1.9.0 (Apple Git-128)",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"git version 2.4.0",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"git version 2.24.3 (Apple Git-128)",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.versionStr, func(t *testing.T) {
|
||||
result := isGitVersionValid(s.versionStr)
|
||||
assert.Equal(t, result, s.expectedResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b
|
||||
return false
|
||||
}
|
||||
|
||||
return (binding.Description != "" || binding.Alternative != "")
|
||||
return (binding.Description != "" || binding.Alternative != "") && binding.Key != nil
|
||||
})
|
||||
|
||||
bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header {
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
expected: []*bindingSection{
|
||||
@@ -36,6 +37,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -47,6 +49,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "",
|
||||
Description: "quit",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
expected: []*bindingSection{
|
||||
@@ -56,6 +59,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "",
|
||||
Description: "quit",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -67,14 +71,17 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "submodules",
|
||||
Description: "drop submodule",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
expected: []*bindingSection{
|
||||
@@ -84,10 +91,12 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -97,6 +106,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "submodules",
|
||||
Description: "drop submodule",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -108,19 +118,23 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "scroll",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "revert commit",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
expected: []*bindingSection{
|
||||
@@ -130,6 +144,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "scroll",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
},
|
||||
@@ -140,6 +155,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "revert commit",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,10 +165,12 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -164,28 +182,34 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "scroll",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "revert commit",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "scroll",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "page up",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
},
|
||||
@@ -196,11 +220,13 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "scroll",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "page up",
|
||||
Key: 'a',
|
||||
Tag: "navigation",
|
||||
},
|
||||
},
|
||||
@@ -211,6 +237,7 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "commits",
|
||||
Description: "revert commit",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -220,10 +247,12 @@ func TestGetBindingSections(t *testing.T) {
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "stage file",
|
||||
Key: 'a',
|
||||
},
|
||||
{
|
||||
ViewName: "files",
|
||||
Description: "unstage file",
|
||||
Key: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+5
-2
@@ -53,6 +53,7 @@ type Loaders struct {
|
||||
|
||||
func NewGitCommand(
|
||||
cmn *common.Common,
|
||||
version *git_commands.GitVersion,
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitConfig git_config.IGitConfig,
|
||||
syncMutex *deadlock.Mutex,
|
||||
@@ -73,6 +74,7 @@ func NewGitCommand(
|
||||
|
||||
return NewGitCommandAux(
|
||||
cmn,
|
||||
version,
|
||||
osCommand,
|
||||
gitConfig,
|
||||
dotGitDir,
|
||||
@@ -83,6 +85,7 @@ func NewGitCommand(
|
||||
|
||||
func NewGitCommandAux(
|
||||
cmn *common.Common,
|
||||
version *git_commands.GitVersion,
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitConfig git_config.IGitConfig,
|
||||
dotGitDir string,
|
||||
@@ -100,7 +103,7 @@ func NewGitCommandAux(
|
||||
|
||||
fileLoader := git_commands.NewFileLoader(cmn, cmd, configCommands)
|
||||
|
||||
gitCommon := git_commands.NewGitCommon(cmn, cmd, osCommand, dotGitDir, repo, configCommands, syncMutex)
|
||||
gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, dotGitDir, repo, configCommands, syncMutex)
|
||||
statusCommands := git_commands.NewStatusCommands(gitCommon)
|
||||
flowCommands := git_commands.NewFlowCommands(gitCommon)
|
||||
remoteCommands := git_commands.NewRemoteCommands(gitCommon)
|
||||
@@ -125,7 +128,7 @@ func NewGitCommandAux(
|
||||
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
|
||||
remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes)
|
||||
stashLoader := git_commands.NewStashLoader(cmn, cmd)
|
||||
tagLoader := git_commands.NewTagLoader(cmn, cmd)
|
||||
tagLoader := git_commands.NewTagLoader(cmn, version, cmd)
|
||||
|
||||
return &GitCommand{
|
||||
Branch: branchCommands,
|
||||
|
||||
@@ -2,19 +2,12 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
// this takes something like:
|
||||
// * (HEAD detached at 264fc6f5)
|
||||
// remotes
|
||||
// and returns '264fc6f5' as the second match
|
||||
const CurrentBranchNameRegex = `(?m)^\*.*?([^ ]*?)\)?$`
|
||||
|
||||
type BranchCommands struct {
|
||||
*GitCommon
|
||||
}
|
||||
@@ -41,19 +34,18 @@ func (self *BranchCommands) CurrentBranchInfo() (BranchInfo, error) {
|
||||
DetachedHead: false,
|
||||
}, nil
|
||||
}
|
||||
output, err := self.cmd.New("git branch --contains").DontLog().RunWithOutput()
|
||||
output, err := self.cmd.New(`git branch --points-at=HEAD --format="%(HEAD)%00%(objectname)%00%(refname)"`).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return BranchInfo{}, err
|
||||
}
|
||||
for _, line := range utils.SplitLines(output) {
|
||||
re := regexp.MustCompile(CurrentBranchNameRegex)
|
||||
match := re.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
branchName = match[1]
|
||||
displayBranchName := match[0][2:]
|
||||
split := strings.Split(strings.TrimRight(line, "\r\n"), "\x00")
|
||||
if len(split) == 3 && split[0] == "*" {
|
||||
sha := split[1]
|
||||
displayName := split[2]
|
||||
return BranchInfo{
|
||||
RefName: branchName,
|
||||
DisplayName: displayBranchName,
|
||||
RefName: sha,
|
||||
DisplayName: displayName,
|
||||
DetachedHead: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -181,26 +181,30 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"falls back to git `git branch --contains` if symbolic-ref fails",
|
||||
"falls back to git `git branch --points-at=HEAD` if symbolic-ref fails",
|
||||
oscommands.NewFakeRunner(t).
|
||||
Expect(`git symbolic-ref --short HEAD`, "", errors.New("error")).
|
||||
Expect(`git branch --contains`, "* (HEAD detached at 8982166a)", nil),
|
||||
Expect(`git branch --points-at=HEAD --format="%(HEAD)%00%(objectname)%00%(refname)"`, "*\x006f71c57a8d4bd6c11399c3f55f42c815527a73a4\x00(HEAD detached at 6f71c57a)\n", nil),
|
||||
func(info BranchInfo, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "8982166a", info.RefName)
|
||||
assert.EqualValues(t, "(HEAD detached at 8982166a)", info.DisplayName)
|
||||
assert.EqualValues(t, "6f71c57a8d4bd6c11399c3f55f42c815527a73a4", info.RefName)
|
||||
assert.EqualValues(t, "(HEAD detached at 6f71c57a)", info.DisplayName)
|
||||
assert.True(t, info.DetachedHead)
|
||||
},
|
||||
},
|
||||
{
|
||||
"handles a detached head",
|
||||
"handles a detached head (LANG=zh_CN.UTF-8)",
|
||||
oscommands.NewFakeRunner(t).
|
||||
Expect(`git symbolic-ref --short HEAD`, "", errors.New("error")).
|
||||
Expect(`git branch --contains`, "* (HEAD detached at 123abcd)", nil),
|
||||
Expect(
|
||||
`git branch --points-at=HEAD --format="%(HEAD)%00%(objectname)%00%(refname)"`,
|
||||
"*\x00679b0456f3db7c505b398def84e7d023e5b55a8d\x00(头指针在 679b0456 分离)\n"+
|
||||
" \x00679b0456f3db7c505b398def84e7d023e5b55a8d\x00refs/heads/master\n",
|
||||
nil),
|
||||
func(info BranchInfo, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "123abcd", info.RefName)
|
||||
assert.EqualValues(t, "(HEAD detached at 123abcd)", info.DisplayName)
|
||||
assert.EqualValues(t, "679b0456f3db7c505b398def84e7d023e5b55a8d", info.RefName)
|
||||
assert.EqualValues(t, "(头指针在 679b0456 分离)", info.DisplayName)
|
||||
assert.True(t, info.DetachedHead)
|
||||
},
|
||||
},
|
||||
@@ -208,7 +212,7 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
|
||||
"bubbles up error if there is one",
|
||||
oscommands.NewFakeRunner(t).
|
||||
Expect(`git symbolic-ref --short HEAD`, "", errors.New("error")).
|
||||
Expect(`git branch --contains`, "", errors.New("error")),
|
||||
Expect(`git branch --points-at=HEAD --format="%(HEAD)%00%(objectname)%00%(refname)"`, "", errors.New("error")),
|
||||
func(info BranchInfo, err error) {
|
||||
assert.Error(t, err)
|
||||
assert.EqualValues(t, "", info.RefName)
|
||||
|
||||
@@ -62,7 +62,7 @@ func (self *CommitCommands) CommitCmdObj(message string) oscommands.ICmdObj {
|
||||
|
||||
// runs git commit without the -m argument meaning it will invoke the user's editor
|
||||
func (self *CommitCommands) CommitEditorCmdObj() oscommands.ICmdObj {
|
||||
return self.cmd.New(fmt.Sprintf("git commit%s", self.signoffFlag()))
|
||||
return self.cmd.New(fmt.Sprintf("git commit%s%s", self.signoffFlag(), self.verboseFlag()))
|
||||
}
|
||||
|
||||
func (self *CommitCommands) signoffFlag() string {
|
||||
@@ -73,6 +73,17 @@ func (self *CommitCommands) signoffFlag() string {
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CommitCommands) verboseFlag() string {
|
||||
switch self.config.UserConfig.Git.Commit.Verbose {
|
||||
case "always":
|
||||
return " --verbose"
|
||||
case "never":
|
||||
return " --no-verbose"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Get the subject of the HEAD commit
|
||||
func (self *CommitCommands) GetHeadCommitMessage() (string, error) {
|
||||
message, err := self.cmd.New("git log -1 --pretty=%s").DontLog().RunWithOutput()
|
||||
|
||||
@@ -426,7 +426,10 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
|
||||
|
||||
config := self.UserConfig.Git.Log
|
||||
|
||||
orderFlag := "--" + config.Order
|
||||
orderFlag := ""
|
||||
if config.Order != "default" {
|
||||
orderFlag = " --" + config.Order
|
||||
}
|
||||
allFlag := ""
|
||||
if opts.All {
|
||||
allFlag = " --all"
|
||||
@@ -434,7 +437,7 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
|
||||
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf(
|
||||
"git -c log.showSignature=false log %s %s %s --oneline %s%s --abbrev=%d%s",
|
||||
"git -c log.showSignature=false log %s%s%s --oneline %s%s --abbrev=%d%s",
|
||||
self.cmd.Quote(opts.RefName),
|
||||
orderFlag,
|
||||
allFlag,
|
||||
@@ -446,14 +449,4 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
|
||||
).DontLog()
|
||||
}
|
||||
|
||||
var prettyFormat = fmt.Sprintf(
|
||||
"--pretty=format:\"%%H%s%%at%s%%aN%s%%ae%s%%d%s%%p%s%%s\"",
|
||||
NULL_CODE,
|
||||
NULL_CODE,
|
||||
NULL_CODE,
|
||||
NULL_CODE,
|
||||
NULL_CODE,
|
||||
NULL_CODE,
|
||||
)
|
||||
|
||||
const NULL_CODE = "%x00"
|
||||
const prettyFormat = `--pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s"`
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestGetCommits(t *testing.T) {
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
expectedCommits []*models.Commit
|
||||
expectedError error
|
||||
logOrder string
|
||||
rebaseMode enums.RebaseMode
|
||||
currentBranchName string
|
||||
opts GetCommitsOptions
|
||||
@@ -35,18 +36,20 @@ func TestGetCommits(t *testing.T) {
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "should return no commits if there are none",
|
||||
logOrder: "topo-order",
|
||||
rebaseMode: enums.REBASE_MODE_NONE,
|
||||
currentBranchName: "master",
|
||||
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
|
||||
Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, "", nil),
|
||||
Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, "", nil),
|
||||
|
||||
expectedCommits: []*models.Commit{},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return commits if they are present",
|
||||
logOrder: "topo-order",
|
||||
rebaseMode: enums.REBASE_MODE_NONE,
|
||||
currentBranchName: "master",
|
||||
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
|
||||
@@ -54,7 +57,7 @@ func TestGetCommits(t *testing.T) {
|
||||
// here it's seeing which commits are yet to be pushed
|
||||
Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
|
||||
// here it's actually getting all the commits in a formatted form, one per line
|
||||
Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, commitsOutput, nil).
|
||||
Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, commitsOutput, nil).
|
||||
// here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged'
|
||||
Expect(`git merge-base "HEAD" "master"`, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil),
|
||||
|
||||
@@ -174,13 +177,29 @@ func TestGetCommits(t *testing.T) {
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should not specify order if `log.order` is `default`",
|
||||
logOrder: "default",
|
||||
rebaseMode: enums.REBASE_MODE_NONE,
|
||||
currentBranchName: "master",
|
||||
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
|
||||
Expect(`git -c log.showSignature=false log "HEAD" --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, "", nil),
|
||||
|
||||
expectedCommits: []*models.Commit{},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario := scenario
|
||||
t.Run(scenario.testName, func(t *testing.T) {
|
||||
common := utils.NewDummyCommon()
|
||||
common.UserConfig.Git.Log.Order = scenario.logOrder
|
||||
|
||||
builder := &CommitLoader{
|
||||
Common: utils.NewDummyCommon(),
|
||||
Common: common,
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
|
||||
getCurrentBranchInfo: func() (BranchInfo, error) {
|
||||
return BranchInfo{RefName: scenario.currentBranchName, DisplayName: scenario.currentBranchName, DetachedHead: false}, nil
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestCommitResetToCommit(t *testing.T) {
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCommitCommitObj(t *testing.T) {
|
||||
func TestCommitCommitCmdObj(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
message string
|
||||
@@ -89,6 +89,62 @@ func TestCommitCommitObj(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitCommitEditorCmdObj(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
configSignoff bool
|
||||
configVerbose string
|
||||
expected string
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "Commit using editor",
|
||||
configSignoff: false,
|
||||
configVerbose: "default",
|
||||
expected: `git commit`,
|
||||
},
|
||||
{
|
||||
testName: "Commit with --no-verbose flag",
|
||||
configSignoff: false,
|
||||
configVerbose: "never",
|
||||
expected: `git commit --no-verbose`,
|
||||
},
|
||||
{
|
||||
testName: "Commit with --verbose flag",
|
||||
configSignoff: false,
|
||||
configVerbose: "always",
|
||||
expected: `git commit --verbose`,
|
||||
},
|
||||
{
|
||||
testName: "Commit with --signoff",
|
||||
configSignoff: true,
|
||||
configVerbose: "default",
|
||||
expected: `git commit --signoff`,
|
||||
},
|
||||
{
|
||||
testName: "Commit with --signoff and --no-verbose",
|
||||
configSignoff: true,
|
||||
configVerbose: "never",
|
||||
expected: `git commit --signoff --no-verbose`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
userConfig := config.GetDefaultConfig()
|
||||
userConfig.Git.Commit.SignOff = s.configSignoff
|
||||
userConfig.Git.Commit.Verbose = s.configVerbose
|
||||
|
||||
instance := buildCommitCommands(commonDeps{userConfig: userConfig})
|
||||
|
||||
cmdStr := instance.CommitEditorCmdObj().ToString()
|
||||
assert.Equal(t, s.expected, cmdStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitCreateFixupCommit(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type GitCommon struct {
|
||||
*common.Common
|
||||
version *GitVersion
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
os *oscommands.OSCommand
|
||||
dotGitDir string
|
||||
@@ -20,6 +21,7 @@ type GitCommon struct {
|
||||
|
||||
func NewGitCommon(
|
||||
cmn *common.Common,
|
||||
version *GitVersion,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
osCommand *oscommands.OSCommand,
|
||||
dotGitDir string,
|
||||
@@ -29,6 +31,7 @@ func NewGitCommon(
|
||||
) *GitCommon {
|
||||
return &GitCommon{
|
||||
Common: cmn,
|
||||
version: version,
|
||||
cmd: cmd,
|
||||
os: osCommand,
|
||||
dotGitDir: dotGitDir,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type commonDeps struct {
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
userConfig *config.UserConfig
|
||||
gitVersion *GitVersion
|
||||
gitConfig *git_config.FakeGitConfig
|
||||
getenv func(string) string
|
||||
removeFile func(string) error
|
||||
@@ -48,6 +49,11 @@ func buildGitCommon(deps commonDeps) *GitCommon {
|
||||
gitCommon.Common.UserConfig = config.GetDefaultConfig()
|
||||
}
|
||||
|
||||
gitCommon.version = deps.gitVersion
|
||||
if gitCommon.version == nil {
|
||||
gitCommon.version = &GitVersion{2, 0, 0, ""}
|
||||
}
|
||||
|
||||
gitConfig := deps.gitConfig
|
||||
if gitConfig == nil {
|
||||
gitConfig = git_config.NewFakeGitConfig(nil)
|
||||
|
||||
@@ -27,7 +27,7 @@ func (self *FileCommands) Cat(fileName string) (string, error) {
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (self *FileCommands) GetEditCmdStr(filename string, lineNumber int) (string, error) {
|
||||
func (self *FileCommands) GetEditor() (string, error) {
|
||||
editor := self.UserConfig.OS.EditCommand
|
||||
|
||||
if editor == "" {
|
||||
@@ -50,6 +50,14 @@ func (self *FileCommands) GetEditCmdStr(filename string, lineNumber int) (string
|
||||
if editor == "" {
|
||||
return "", errors.New("No editor defined in config file, $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
|
||||
}
|
||||
return editor, nil
|
||||
}
|
||||
|
||||
func (self *FileCommands) GetEditCmdStr(filename string, lineNumber int) (string, error) {
|
||||
editor, err := self.GetEditor()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"editor": editor,
|
||||
|
||||
@@ -45,7 +45,7 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
|
||||
remotes := slices.Map(goGitRemotes, func(goGitRemote *gogit.Remote) *models.Remote {
|
||||
remoteName := goGitRemote.Config().Name
|
||||
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%s\/([\S]+)`, remoteName))
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%s\/([\S]+)`, regexp.QuoteMeta(remoteName)))
|
||||
matches := re.FindAllStringSubmatch(remoteBranchesStr, -1)
|
||||
branches := slices.Map(matches, func(match []string) *models.RemoteBranch {
|
||||
return &models.RemoteBranch{
|
||||
|
||||
@@ -29,7 +29,11 @@ func (self *SyncCommands) PushCmdObj(opts PushOpts) (oscommands.ICmdObj, error)
|
||||
cmdStr := "git push"
|
||||
|
||||
if opts.Force {
|
||||
cmdStr += " --force-with-lease"
|
||||
if self.version.IsOlderThan(2, 30, 0) {
|
||||
cmdStr += " --force-with-lease"
|
||||
} else {
|
||||
cmdStr += " --force-with-lease --force-if-includes"
|
||||
}
|
||||
}
|
||||
|
||||
if opts.SetUpstream {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
func TestSyncPush(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
version *GitVersion
|
||||
opts PushOpts
|
||||
test func(oscommands.ICmdObj, error)
|
||||
}
|
||||
@@ -17,6 +18,7 @@ func TestSyncPush(t *testing.T) {
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "Push with force disabled",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{Force: false},
|
||||
test: func(cmdObj oscommands.ICmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.ToString(), "git push")
|
||||
@@ -25,14 +27,25 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "Push with force enabled",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{Force: true},
|
||||
test: func(cmdObj oscommands.ICmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.ToString(), "git push --force-with-lease")
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with force enabled (>= 2.30.0)",
|
||||
version: &GitVersion{2, 30, 0, ""},
|
||||
opts: PushOpts{Force: true},
|
||||
test: func(cmdObj oscommands.ICmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.ToString(), "git push --force-with-lease --force-if-includes")
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with force disabled, upstream supplied",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{
|
||||
Force: false,
|
||||
UpstreamRemote: "origin",
|
||||
@@ -45,6 +58,7 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "Push with force disabled, setting upstream",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{
|
||||
Force: false,
|
||||
UpstreamRemote: "origin",
|
||||
@@ -58,6 +72,7 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "Push with force enabled, setting upstream",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{
|
||||
Force: true,
|
||||
UpstreamRemote: "origin",
|
||||
@@ -69,8 +84,23 @@ func TestSyncPush(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with force enabled, setting upstream (>= 2.30.0)",
|
||||
version: &GitVersion{2, 30, 0, ""},
|
||||
opts: PushOpts{
|
||||
Force: true,
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj oscommands.ICmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.ToString(), `git push --force-with-lease --force-if-includes --set-upstream "origin" "master"`)
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with remote branch but no origin",
|
||||
version: &GitVersion{2, 29, 3, ""},
|
||||
opts: PushOpts{
|
||||
Force: true,
|
||||
UpstreamRemote: "",
|
||||
@@ -87,7 +117,7 @@ func TestSyncPush(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := buildSyncCommands(commonDeps{})
|
||||
instance := buildSyncCommands(commonDeps{gitVersion: s.version})
|
||||
s.test(instance.PushCmdObj(s.opts))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/generics/slices"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
@@ -10,23 +12,31 @@ import (
|
||||
|
||||
type TagLoader struct {
|
||||
*common.Common
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
version *GitVersion
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
}
|
||||
|
||||
func NewTagLoader(
|
||||
common *common.Common,
|
||||
version *GitVersion,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) *TagLoader {
|
||||
return &TagLoader{
|
||||
Common: common,
|
||||
cmd: cmd,
|
||||
Common: common,
|
||||
version: version,
|
||||
cmd: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *TagLoader) GetTags() ([]*models.Tag, error) {
|
||||
// get remote branches, sorted by creation date (descending)
|
||||
// see: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---sortltkeygt
|
||||
tagsOutput, err := self.cmd.New(`git tag --list --sort=-creatordate`).DontLog().RunWithOutput()
|
||||
sortKey := "-creatordate"
|
||||
if self.version.IsOlderThan(2, 7, 0) {
|
||||
sortKey = "-v:refname"
|
||||
}
|
||||
|
||||
tagsOutput, err := self.cmd.New(fmt.Sprintf(`git tag --list --sort=%s`, sortKey)).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ testtag
|
||||
func TestGetTags(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
gitVersion *GitVersion
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
expectedTags []*models.Tag
|
||||
expectedError error
|
||||
@@ -27,14 +28,24 @@ func TestGetTags(t *testing.T) {
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "should return no tags if there are none",
|
||||
testName: "should return no tags if there are none",
|
||||
gitVersion: &GitVersion{2, 7, 0, ""},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git tag --list --sort=-creatordate`, "", nil),
|
||||
expectedTags: []*models.Tag{},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return tags if present",
|
||||
testName: "should return no tags if there are none (< 2.7.0)",
|
||||
gitVersion: &GitVersion{2, 6, 7, ""},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git tag --list --sort=-v:refname`, "", nil),
|
||||
expectedTags: []*models.Tag{},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return tags if present",
|
||||
gitVersion: &GitVersion{2, 7, 0, ""},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git tag --list --sort=-creatordate`, tagsOutput, nil),
|
||||
expectedTags: []*models.Tag{
|
||||
@@ -47,15 +58,31 @@ func TestGetTags(t *testing.T) {
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return tags if present (< 2.7.0)",
|
||||
gitVersion: &GitVersion{2, 6, 7, ""},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
Expect(`git tag --list --sort=-v:refname`, tagsOutput, nil),
|
||||
expectedTags: []*models.Tag{
|
||||
{Name: "v0.34"},
|
||||
{Name: "v0.33"},
|
||||
{Name: "v0.32.2"},
|
||||
{Name: "v0.32.1"},
|
||||
{Name: "v0.32"},
|
||||
{Name: "testtag"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario := scenario
|
||||
t.Run(scenario.testName, func(t *testing.T) {
|
||||
loader := &TagLoader{
|
||||
Common: utils.NewDummyCommon(),
|
||||
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
|
||||
}
|
||||
loader := NewTagLoader(
|
||||
utils.NewDummyCommon(),
|
||||
scenario.gitVersion,
|
||||
oscommands.NewDummyCmdObjBuilder(scenario.runner),
|
||||
)
|
||||
|
||||
tags, err := loader.GetTags()
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
)
|
||||
|
||||
type GitVersion struct {
|
||||
Major, Minor, Patch int
|
||||
Additional string
|
||||
}
|
||||
|
||||
func GetGitVersion(osCommand *oscommands.OSCommand) (*GitVersion, error) {
|
||||
versionStr, _, err := osCommand.Cmd.New("git --version").RunWithOutputs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
version, err := ParseGitVersion(versionStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func ParseGitVersion(versionStr string) (*GitVersion, error) {
|
||||
// versionStr should be something like:
|
||||
// git version 2.39.0
|
||||
// git version 2.37.1 (Apple Git-137.1)
|
||||
re := regexp.MustCompile(`[^\d]+(\d+)(\.\d+)?(\.\d+)?(.*)`)
|
||||
matches := re.FindStringSubmatch(versionStr)
|
||||
|
||||
if len(matches) < 5 {
|
||||
return nil, errors.New("unexpected git version format: " + versionStr)
|
||||
}
|
||||
|
||||
v := &GitVersion{}
|
||||
var err error
|
||||
|
||||
if v.Major, err = strconv.Atoi(matches[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(matches[2]) > 1 {
|
||||
if v.Minor, err = strconv.Atoi(matches[2][1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(matches[3]) > 1 {
|
||||
if v.Patch, err = strconv.Atoi(matches[3][1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
v.Additional = strings.Trim(matches[4], " \r\n")
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (v *GitVersion) IsOlderThan(major, minor, patch int) bool {
|
||||
actual := v.Major*1000*1000 + v.Minor*1000 + v.Patch
|
||||
required := major*1000*1000 + minor*1000 + patch
|
||||
return actual < required
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseGitVersion(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
input string
|
||||
expected GitVersion
|
||||
}{
|
||||
{
|
||||
input: "git version 2.39.0",
|
||||
expected: GitVersion{Major: 2, Minor: 39, Patch: 0, Additional: ""},
|
||||
},
|
||||
{
|
||||
input: "git version 2.37.1 (Apple Git-137.1)",
|
||||
expected: GitVersion{Major: 2, Minor: 37, Patch: 1, Additional: "(Apple Git-137.1)"},
|
||||
},
|
||||
{
|
||||
input: "git version 2.37 (Apple Git-137.1)",
|
||||
expected: GitVersion{Major: 2, Minor: 37, Patch: 0, Additional: "(Apple Git-137.1)"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
actual, err := ParseGitVersion(s.input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, actual)
|
||||
assert.Equal(t, s.expected.Major, actual.Major)
|
||||
assert.Equal(t, s.expected.Minor, actual.Minor)
|
||||
assert.Equal(t, s.expected.Patch, actual.Patch)
|
||||
assert.Equal(t, s.expected.Additional, actual.Additional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitVersionIsOlderThan(t *testing.T) {
|
||||
assert.False(t, (&GitVersion{2, 0, 0, ""}).IsOlderThan(1, 99, 99))
|
||||
assert.False(t, (&GitVersion{2, 0, 0, ""}).IsOlderThan(2, 0, 0))
|
||||
assert.False(t, (&GitVersion{2, 1, 0, ""}).IsOlderThan(2, 0, 9))
|
||||
|
||||
assert.True(t, (&GitVersion{2, 0, 1, ""}).IsOlderThan(2, 1, 0))
|
||||
assert.True(t, (&GitVersion{2, 0, 1, ""}).IsOlderThan(3, 0, 0))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
gogit "github.com/jesseduffield/go-git/v5"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -218,6 +219,7 @@ func TestNewGitCommand(t *testing.T) {
|
||||
s.setup()
|
||||
s.test(
|
||||
NewGitCommand(utils.NewDummyCommon(),
|
||||
&git_commands.GitVersion{},
|
||||
oscommands.NewDummyOSCommand(),
|
||||
git_config.NewFakeGitConfig(nil),
|
||||
&deadlock.Mutex{},
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
Password CredentialType = iota
|
||||
Username
|
||||
Passphrase
|
||||
PIN
|
||||
)
|
||||
|
||||
type cmdObjRunner struct {
|
||||
@@ -335,6 +336,7 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede
|
||||
`Password\s*for\s*'.+':`: Password,
|
||||
`Username\s*for\s*'.+':`: Username,
|
||||
`Enter\s*passphrase\s*for\s*key\s*'.+':`: Passphrase,
|
||||
`Enter\s*PIN\s*for\s*.+\s*key\s*.+:`: PIN,
|
||||
}
|
||||
|
||||
for pattern, askFor := range prompts {
|
||||
|
||||
@@ -202,10 +202,6 @@ func (p *PatchParser) Render(isFocused bool, firstLineIndex int, lastLineIndex i
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *PatchParser) RenderPlain() string {
|
||||
return renderLinesPlain(p.PatchLines)
|
||||
}
|
||||
|
||||
// RenderLinesPlain returns the non-coloured string of diff part from firstLineIndex to
|
||||
// lastLineIndex
|
||||
func (p *PatchParser) RenderLinesPlain(firstLineIndex, lastLineIndex int) string {
|
||||
@@ -214,10 +210,10 @@ func (p *PatchParser) RenderLinesPlain(firstLineIndex, lastLineIndex int) string
|
||||
|
||||
func renderLinesPlain(lines []*PatchLine) string {
|
||||
renderedLines := slices.Map(lines, func(line *PatchLine) string {
|
||||
return line.Content
|
||||
return line.Content + "\n"
|
||||
})
|
||||
|
||||
return strings.Join(renderedLines, "\n")
|
||||
return strings.Join(renderedLines, "")
|
||||
}
|
||||
|
||||
// GetNextStageableLineIndex takes a line index and returns the line index of the next stageable line
|
||||
|
||||
+45
-40
@@ -9,8 +9,6 @@ type UserConfig struct {
|
||||
Git GitConfig `yaml:"git"`
|
||||
Update UpdateConfig `yaml:"update"`
|
||||
Refresher RefresherConfig `yaml:"refresher"`
|
||||
Reporting string `yaml:"reporting"`
|
||||
SplashUpdatesIndex int `yaml:"splashUpdatesIndex"`
|
||||
ConfirmOnQuit bool `yaml:"confirmOnQuit"`
|
||||
QuitOnTopLevelReturn bool `yaml:"quitOnTopLevelReturn"`
|
||||
Keybinding KeybindingConfig `yaml:"keybinding"`
|
||||
@@ -29,29 +27,31 @@ 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"`
|
||||
SkipUnstageLineWarning bool `yaml:"skipUnstageLineWarning"`
|
||||
SkipStashWarning bool `yaml:"skipStashWarning"`
|
||||
SidePanelWidth float64 `yaml:"sidePanelWidth"`
|
||||
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
|
||||
MainPanelSplitMode string `yaml:"mainPanelSplitMode"`
|
||||
Language string `yaml:"language"`
|
||||
TimeFormat string `yaml:"timeFormat"`
|
||||
Theme ThemeConfig `yaml:"theme"`
|
||||
CommitLength CommitLengthConfig `yaml:"commitLength"`
|
||||
SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"`
|
||||
ShowListFooter bool `yaml:"showListFooter"`
|
||||
ShowFileTree bool `yaml:"showFileTree"`
|
||||
ShowRandomTip bool `yaml:"showRandomTip"`
|
||||
ShowCommandLog bool `yaml:"showCommandLog"`
|
||||
ShowBottomLine bool `yaml:"showBottomLine"`
|
||||
ShowIcons bool `yaml:"showIcons"`
|
||||
CommandLogSize int `yaml:"commandLogSize"`
|
||||
SplitDiff string `yaml:"splitDiff"`
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
BranchColors map[string]string `yaml:"branchColors"`
|
||||
ScrollHeight int `yaml:"scrollHeight"`
|
||||
ScrollPastBottom bool `yaml:"scrollPastBottom"`
|
||||
MouseEvents bool `yaml:"mouseEvents"`
|
||||
SkipUnstageLineWarning bool `yaml:"skipUnstageLineWarning"`
|
||||
SkipStashWarning bool `yaml:"skipStashWarning"`
|
||||
SidePanelWidth float64 `yaml:"sidePanelWidth"`
|
||||
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
|
||||
MainPanelSplitMode string `yaml:"mainPanelSplitMode"`
|
||||
Language string `yaml:"language"`
|
||||
TimeFormat string `yaml:"timeFormat"`
|
||||
Theme ThemeConfig `yaml:"theme"`
|
||||
CommitLength CommitLengthConfig `yaml:"commitLength"`
|
||||
SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"`
|
||||
ShowListFooter bool `yaml:"showListFooter"`
|
||||
ShowFileTree bool `yaml:"showFileTree"`
|
||||
ShowRandomTip bool `yaml:"showRandomTip"`
|
||||
ShowCommandLog bool `yaml:"showCommandLog"`
|
||||
ShowBottomLine bool `yaml:"showBottomLine"`
|
||||
ShowIcons bool `yaml:"showIcons"`
|
||||
CommandLogSize int `yaml:"commandLogSize"`
|
||||
SplitDiff string `yaml:"splitDiff"`
|
||||
SkipRewordInEditorWarning bool `yaml:"skipRewordInEditorWarning"`
|
||||
WindowSize string `yaml:"windowSize"`
|
||||
}
|
||||
|
||||
type ThemeConfig struct {
|
||||
@@ -63,6 +63,7 @@ type ThemeConfig struct {
|
||||
CherryPickedCommitBgColor []string `yaml:"cherryPickedCommitBgColor"`
|
||||
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor"`
|
||||
UnstagedChangesColor []string `yaml:"unstagedChangesColor"`
|
||||
DefaultFgColor []string `yaml:"defaultFgColor"`
|
||||
}
|
||||
|
||||
type CommitLengthConfig struct {
|
||||
@@ -94,7 +95,8 @@ type PagingConfig struct {
|
||||
}
|
||||
|
||||
type CommitConfig struct {
|
||||
SignOff bool `yaml:"signOff"`
|
||||
SignOff bool `yaml:"signOff"`
|
||||
Verbose string `yaml:"verbose"`
|
||||
}
|
||||
|
||||
type MergingConfig struct {
|
||||
@@ -135,6 +137,7 @@ type KeybindingUniversalConfig struct {
|
||||
Quit string `yaml:"quit"`
|
||||
QuitAlt1 string `yaml:"quit-alt1"`
|
||||
Return string `yaml:"return"`
|
||||
ReturnAlt1 string `yaml:"return-alt1"`
|
||||
QuitWithoutChangingDirectory string `yaml:"quitWithoutChangingDirectory"`
|
||||
TogglePanel string `yaml:"togglePanel"`
|
||||
PrevItem string `yaml:"prevItem"`
|
||||
@@ -209,7 +212,7 @@ type KeybindingFilesConfig struct {
|
||||
CommitChangesWithoutHook string `yaml:"commitChangesWithoutHook"`
|
||||
AmendLastCommit string `yaml:"amendLastCommit"`
|
||||
CommitChangesWithEditor string `yaml:"commitChangesWithEditor"`
|
||||
IgnoreOrExcludeFile string `yaml:"IgnoreOrExcludeFile"`
|
||||
IgnoreFile string `yaml:"ignoreFile"`
|
||||
RefreshFiles string `yaml:"refreshFiles"`
|
||||
StashAllChanges string `yaml:"stashAllChanges"`
|
||||
ViewStashOptions string `yaml:"viewStashOptions"`
|
||||
@@ -365,17 +368,19 @@ func GetDefaultConfig() *UserConfig {
|
||||
CherryPickedCommitBgColor: []string{"cyan"},
|
||||
CherryPickedCommitFgColor: []string{"blue"},
|
||||
UnstagedChangesColor: []string{"red"},
|
||||
DefaultFgColor: []string{"default"},
|
||||
},
|
||||
CommitLength: CommitLengthConfig{Show: true},
|
||||
SkipNoStagedFilesWarning: false,
|
||||
ShowListFooter: true,
|
||||
ShowCommandLog: true,
|
||||
ShowBottomLine: true,
|
||||
ShowFileTree: true,
|
||||
ShowRandomTip: true,
|
||||
ShowIcons: false,
|
||||
CommandLogSize: 8,
|
||||
SplitDiff: "auto",
|
||||
CommitLength: CommitLengthConfig{Show: true},
|
||||
SkipNoStagedFilesWarning: false,
|
||||
ShowListFooter: true,
|
||||
ShowCommandLog: true,
|
||||
ShowBottomLine: true,
|
||||
ShowFileTree: true,
|
||||
ShowRandomTip: true,
|
||||
ShowIcons: false,
|
||||
CommandLogSize: 8,
|
||||
SplitDiff: "auto",
|
||||
SkipRewordInEditorWarning: false,
|
||||
},
|
||||
Git: GitConfig{
|
||||
Paging: PagingConfig{
|
||||
@@ -385,6 +390,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
},
|
||||
Commit: CommitConfig{
|
||||
SignOff: false,
|
||||
Verbose: "default",
|
||||
},
|
||||
Merging: MergingConfig{
|
||||
ManualCommit: false,
|
||||
@@ -413,8 +419,6 @@ func GetDefaultConfig() *UserConfig {
|
||||
Method: "prompt",
|
||||
Days: 14,
|
||||
},
|
||||
Reporting: "undetermined",
|
||||
SplashUpdatesIndex: 0,
|
||||
ConfirmOnQuit: false,
|
||||
QuitOnTopLevelReturn: false,
|
||||
Keybinding: KeybindingConfig{
|
||||
@@ -422,6 +426,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
Quit: "q",
|
||||
QuitAlt1: "<c-c>",
|
||||
Return: "<esc>",
|
||||
ReturnAlt1: "",
|
||||
QuitWithoutChangingDirectory: "Q",
|
||||
TogglePanel: "<tab>",
|
||||
PrevItem: "<up>",
|
||||
@@ -494,7 +499,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
CommitChangesWithoutHook: "w",
|
||||
AmendLastCommit: "A",
|
||||
CommitChangesWithEditor: "C",
|
||||
IgnoreOrExcludeFile: "i",
|
||||
IgnoreFile: "i",
|
||||
RefreshFiles: "r",
|
||||
StashAllChanges: "s",
|
||||
ViewStashOptions: "S",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
func (gui *Gui) startBackgroundRoutines() {
|
||||
userConfig := gui.UserConfig
|
||||
|
||||
if userConfig.Git.AutoFetch {
|
||||
fetchInterval := userConfig.Refresher.FetchInterval
|
||||
if fetchInterval > 0 {
|
||||
go utils.Safe(gui.startBackgroundFetch)
|
||||
} else {
|
||||
gui.c.Log.Errorf(
|
||||
"Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch",
|
||||
fetchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if userConfig.Git.AutoRefresh {
|
||||
refreshInterval := userConfig.Refresher.RefreshInterval
|
||||
if refreshInterval > 0 {
|
||||
gui.goEvery(time.Second*time.Duration(refreshInterval), gui.stopChan, gui.refreshFilesAndSubmodules)
|
||||
} else {
|
||||
gui.c.Log.Errorf(
|
||||
"Value of config option 'refresher.refreshInterval' (%d) is invalid, disabling auto-refresh",
|
||||
refreshInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) startBackgroundFetch() {
|
||||
gui.waitForIntro.Wait()
|
||||
isNew := gui.IsNewRepo
|
||||
userConfig := gui.UserConfig
|
||||
if !isNew {
|
||||
time.After(time.Duration(userConfig.Refresher.FetchInterval) * time.Second)
|
||||
}
|
||||
err := gui.backgroundFetch()
|
||||
if err != nil && strings.Contains(err.Error(), "exit status 128") && isNew {
|
||||
_ = gui.c.Alert(gui.c.Tr.NoAutomaticGitFetchTitle, gui.c.Tr.NoAutomaticGitFetchBody)
|
||||
} else {
|
||||
gui.goEvery(time.Second*time.Duration(userConfig.Refresher.FetchInterval), gui.stopChan, func() error {
|
||||
err := gui.backgroundFetch()
|
||||
gui.render()
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) goEvery(interval time.Duration, stop chan struct{}, function func() error) {
|
||||
go utils.Safe(func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if gui.PauseBackgroundThreads {
|
||||
continue
|
||||
}
|
||||
_ = function()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -16,8 +17,10 @@ import (
|
||||
// This file is for the rendering of confirmation panels along with setting and handling associated
|
||||
// keybindings.
|
||||
|
||||
func (gui *Gui) wrappedConfirmationFunction(function func() error) func() error {
|
||||
func (gui *Gui) wrappedConfirmationFunction(cancel context.CancelFunc, function func() error) func() error {
|
||||
return func() error {
|
||||
cancel()
|
||||
|
||||
if err := gui.c.PopContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -32,8 +35,10 @@ func (gui *Gui) wrappedConfirmationFunction(function func() error) func() error
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) wrappedPromptConfirmationFunction(function func(string) error, getResponse func() string) func() error {
|
||||
func (gui *Gui) wrappedPromptConfirmationFunction(cancel context.CancelFunc, function func(string) error, getResponse func() string) func() error {
|
||||
return func() error {
|
||||
cancel()
|
||||
|
||||
if err := gui.c.PopContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,11 +116,12 @@ func (gui *Gui) getConfirmationPanelWidth() int {
|
||||
}
|
||||
|
||||
func (gui *Gui) prepareConfirmationPanel(
|
||||
ctx context.Context,
|
||||
opts types.ConfirmOpts,
|
||||
) error {
|
||||
gui.Views.Confirmation.HasLoader = opts.HasLoader
|
||||
if opts.HasLoader {
|
||||
gui.g.StartTicking()
|
||||
gui.g.StartTicking(ctx)
|
||||
}
|
||||
gui.Views.Confirmation.Title = opts.Title
|
||||
// for now we do not support wrapping in our editor
|
||||
@@ -145,16 +151,19 @@ func runeForMask(mask bool) rune {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
|
||||
func (gui *Gui) createPopupPanel(ctx context.Context, opts types.CreatePopupPanelOpts) error {
|
||||
gui.Mutexes.PopupMutex.Lock()
|
||||
defer gui.Mutexes.PopupMutex.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// we don't allow interruptions of non-loader popups in case we get stuck somehow
|
||||
// e.g. a credentials popup never gets its required user input so a process hangs
|
||||
// forever.
|
||||
// The proper solution is to have a queue of popup options
|
||||
if gui.State.CurrentPopupOpts != nil && !gui.State.CurrentPopupOpts.HasLoader {
|
||||
gui.Log.Error("ignoring create popup panel because a popup panel is already open")
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -162,6 +171,7 @@ func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
|
||||
gui.clearConfirmationViewKeyBindings()
|
||||
|
||||
err := gui.prepareConfirmationPanel(
|
||||
ctx,
|
||||
types.ConfirmOpts{
|
||||
Title: opts.Title,
|
||||
Prompt: opts.Prompt,
|
||||
@@ -171,6 +181,7 @@ func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
|
||||
Mask: opts.Mask,
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
confirmationView := gui.Views.Confirmation
|
||||
@@ -185,11 +196,13 @@ func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
|
||||
confirmationView.RenderTextArea()
|
||||
} else {
|
||||
if err := gui.renderString(confirmationView, style.AttrBold.Sprint(opts.Prompt)); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := gui.setKeyBindings(opts); err != nil {
|
||||
if err := gui.setKeyBindings(cancel, opts); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -198,7 +211,7 @@ func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
|
||||
return gui.c.PushContext(gui.State.Contexts.Confirmation)
|
||||
}
|
||||
|
||||
func (gui *Gui) setKeyBindings(opts types.CreatePopupPanelOpts) error {
|
||||
func (gui *Gui) setKeyBindings(cancel context.CancelFunc, opts types.CreatePopupPanelOpts) error {
|
||||
actions := utils.ResolvePlaceholderString(
|
||||
gui.c.Tr.CloseConfirm,
|
||||
map[string]string{
|
||||
@@ -210,13 +223,14 @@ func (gui *Gui) setKeyBindings(opts types.CreatePopupPanelOpts) error {
|
||||
_ = gui.renderString(gui.Views.Options, actions)
|
||||
var onConfirm func() error
|
||||
if opts.HandleConfirmPrompt != nil {
|
||||
onConfirm = gui.wrappedPromptConfirmationFunction(opts.HandleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() })
|
||||
onConfirm = gui.wrappedPromptConfirmationFunction(cancel, opts.HandleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() })
|
||||
} else {
|
||||
onConfirm = gui.wrappedConfirmationFunction(opts.HandleConfirm)
|
||||
onConfirm = gui.wrappedConfirmationFunction(cancel, opts.HandleConfirm)
|
||||
}
|
||||
|
||||
keybindingConfig := gui.c.UserConfig.Keybinding
|
||||
onSuggestionConfirm := gui.wrappedPromptConfirmationFunction(
|
||||
cancel,
|
||||
opts.HandleConfirmPrompt,
|
||||
gui.getSelectedSuggestionValue,
|
||||
)
|
||||
@@ -235,7 +249,7 @@ func (gui *Gui) setKeyBindings(opts types.CreatePopupPanelOpts) error {
|
||||
{
|
||||
ViewName: "confirmation",
|
||||
Key: keybindings.GetKey(keybindingConfig.Universal.Return),
|
||||
Handler: gui.wrappedConfirmationFunction(opts.HandleClose),
|
||||
Handler: gui.wrappedConfirmationFunction(cancel, opts.HandleClose),
|
||||
},
|
||||
{
|
||||
ViewName: "confirmation",
|
||||
@@ -260,7 +274,7 @@ func (gui *Gui) setKeyBindings(opts types.CreatePopupPanelOpts) error {
|
||||
{
|
||||
ViewName: "suggestions",
|
||||
Key: keybindings.GetKey(keybindingConfig.Universal.Return),
|
||||
Handler: gui.wrappedConfirmationFunction(opts.HandleClose),
|
||||
Handler: gui.wrappedConfirmationFunction(cancel, opts.HandleClose),
|
||||
},
|
||||
{
|
||||
ViewName: "suggestions",
|
||||
@@ -290,8 +304,13 @@ func (gui *Gui) clearConfirmationViewKeyBindings() {
|
||||
|
||||
func (gui *Gui) refreshSuggestions() {
|
||||
gui.suggestionsAsyncHandler.Do(func() func() {
|
||||
suggestions := gui.findSuggestions(gui.c.GetPromptInput())
|
||||
return func() { gui.setSuggestions(suggestions) }
|
||||
findSuggestionsFn := gui.findSuggestions
|
||||
if findSuggestionsFn != nil {
|
||||
suggestions := gui.findSuggestions(gui.c.GetPromptInput())
|
||||
return func() { gui.setSuggestions(suggestions) }
|
||||
} else {
|
||||
return func() {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
const (
|
||||
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
|
||||
STATUS_CONTEXT_KEY types.ContextKey = "status"
|
||||
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
|
||||
FILES_CONTEXT_KEY types.ContextKey = "files"
|
||||
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
|
||||
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
|
||||
@@ -74,6 +75,7 @@ var AllContextKeys = []types.ContextKey{
|
||||
type ContextTree struct {
|
||||
Global types.Context
|
||||
Status types.Context
|
||||
Snake types.Context
|
||||
Files *WorkingTreeContext
|
||||
Menu *MenuContext
|
||||
Branches *BranchesContext
|
||||
@@ -112,6 +114,7 @@ func (self *ContextTree) Flatten() []types.Context {
|
||||
return []types.Context{
|
||||
self.Global,
|
||||
self.Status,
|
||||
self.Snake,
|
||||
self.Submodules,
|
||||
self.Files,
|
||||
self.SubCommits,
|
||||
|
||||
@@ -32,6 +32,26 @@ func (gui *Gui) contextTree() *context.ContextTree {
|
||||
OnRenderToMain: gui.statusRenderToMain,
|
||||
},
|
||||
),
|
||||
Snake: context.NewSimpleContext(
|
||||
context.NewBaseContext(context.NewBaseContextOpts{
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
View: gui.Views.Snake,
|
||||
WindowName: "files",
|
||||
Key: context.SNAKE_CONTEXT_KEY,
|
||||
Focusable: true,
|
||||
}),
|
||||
context.ContextCallbackOpts{
|
||||
OnFocus: func(opts types.OnFocusOpts) error {
|
||||
gui.startSnake()
|
||||
return nil
|
||||
},
|
||||
OnFocusLost: func(opts types.OnFocusLostOpts) error {
|
||||
gui.snakeGame.Exit()
|
||||
gui.moveToTopOfWindow(gui.State.Contexts.Submodules)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
Files: gui.filesListContext(),
|
||||
Submodules: gui.submodulesListContext(),
|
||||
Menu: gui.menuListContext(),
|
||||
|
||||
+12
-8
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/snake"
|
||||
)
|
||||
|
||||
func (gui *Gui) resetControllers() {
|
||||
@@ -24,6 +25,10 @@ func (gui *Gui) resetControllers() {
|
||||
|
||||
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, gui.State.Contexts, gui.git, refsHelper)
|
||||
suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon, model, gui.refreshSuggestions)
|
||||
setCommitMessage := gui.getSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage })
|
||||
getSavedCommitMessage := func() string {
|
||||
return gui.State.savedCommitMessage
|
||||
}
|
||||
gui.helpers = &helpers.Helpers{
|
||||
Refs: refsHelper,
|
||||
Host: helpers.NewHostHelper(helperCommon, gui.git),
|
||||
@@ -31,7 +36,7 @@ func (gui *Gui) resetControllers() {
|
||||
Bisect: helpers.NewBisectHelper(helperCommon, gui.git),
|
||||
Suggestions: suggestionsHelper,
|
||||
Files: helpers.NewFilesHelper(helperCommon, gui.git, osCommand),
|
||||
WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, gui.git, model),
|
||||
WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, gui.git, gui.State.Contexts, refsHelper, model, setCommitMessage, getSavedCommitMessage),
|
||||
Tags: helpers.NewTagsHelper(helperCommon, gui.git),
|
||||
GPG: helpers.NewGpgHelper(helperCommon, gui.os, gui.git),
|
||||
MergeAndRebase: rebaseHelper,
|
||||
@@ -76,16 +81,10 @@ func (gui *Gui) resetControllers() {
|
||||
|
||||
bisectController := controllers.NewBisectController(common)
|
||||
|
||||
getSavedCommitMessage := func() string {
|
||||
return gui.State.savedCommitMessage
|
||||
}
|
||||
|
||||
getCommitMessage := func() string {
|
||||
return strings.TrimSpace(gui.Views.CommitMessage.TextArea.GetContent())
|
||||
}
|
||||
|
||||
setCommitMessage := gui.getSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage })
|
||||
|
||||
onCommitAttempt := func(message string) {
|
||||
gui.State.savedCommitMessage = message
|
||||
gui.Views.CommitMessage.ClearTextArea()
|
||||
@@ -121,7 +120,7 @@ func (gui *Gui) resetControllers() {
|
||||
undoController := controllers.NewUndoController(common)
|
||||
globalController := controllers.NewGlobalController(common)
|
||||
contextLinesController := controllers.NewContextLinesController(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common, &gui.viewBufferManagerMap)
|
||||
|
||||
branchesController := controllers.NewBranchesController(common)
|
||||
gitFlowController := controllers.NewGitFlowController(common)
|
||||
@@ -132,6 +131,7 @@ func (gui *Gui) resetControllers() {
|
||||
stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false)
|
||||
stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true)
|
||||
patchBuildingController := controllers.NewPatchBuildingController(common)
|
||||
snakeController := controllers.NewSnakeController(common, func() *snake.Game { return gui.snakeGame })
|
||||
|
||||
setSubCommits := func(commits []*models.Commit) { gui.State.Model.SubCommits = commits }
|
||||
|
||||
@@ -250,6 +250,10 @@ func (gui *Gui) resetControllers() {
|
||||
contextLinesController,
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Snake,
|
||||
snakeController,
|
||||
)
|
||||
|
||||
// this must come last so that we've got our click handlers defined against the context
|
||||
listControllerFactory := controllers.NewListControllerFactory(gui.c)
|
||||
for _, context := range gui.getListContexts() {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type FilesController struct {
|
||||
@@ -54,12 +50,12 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChanges),
|
||||
Handler: self.HandleCommitPress,
|
||||
Handler: self.helpers.WorkingTree.HandleCommitPress,
|
||||
Description: self.c.Tr.CommitChanges,
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook),
|
||||
Handler: self.HandleWIPCommitPress,
|
||||
Handler: self.helpers.WorkingTree.HandleWIPCommitPress,
|
||||
Description: self.c.Tr.LcCommitChangesWithoutHook,
|
||||
},
|
||||
{
|
||||
@@ -69,7 +65,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor),
|
||||
Handler: self.HandleCommitEditorPress,
|
||||
Handler: self.helpers.WorkingTree.HandleCommitEditorPress,
|
||||
Description: self.c.Tr.CommitChangesWithEditor,
|
||||
},
|
||||
{
|
||||
@@ -83,7 +79,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
Description: self.c.Tr.LcOpenFile,
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.IgnoreOrExcludeFile),
|
||||
Key: opts.GetKey(opts.Config.Files.IgnoreFile),
|
||||
Handler: self.checkSelectedFileNode(self.ignoreOrExcludeMenu),
|
||||
Description: self.c.Tr.Actions.LcIgnoreExcludeFile,
|
||||
OpensMenu: true,
|
||||
@@ -554,112 +550,17 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) HandleWIPCommitPress() error {
|
||||
skipHookPrefix := self.c.UserConfig.Git.SkipHookPrefix
|
||||
if skipHookPrefix == "" {
|
||||
return self.c.ErrorMsg(self.c.Tr.SkipHookPrefixNotConfigured)
|
||||
}
|
||||
|
||||
self.setCommitMessage(skipHookPrefix)
|
||||
|
||||
return self.HandleCommitPress()
|
||||
}
|
||||
|
||||
func (self *FilesController) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
|
||||
cfg, ok := self.c.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func (self *FilesController) prepareFilesForCommit() error {
|
||||
noStagedFiles := !self.helpers.WorkingTree.AnyStagedFiles()
|
||||
if noStagedFiles && self.c.UserConfig.Gui.SkipNoStagedFilesWarning {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
err := self.git.WorkingTree.StageAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.syncRefresh()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// for when you need to refetch files before continuing an action. Runs synchronously.
|
||||
func (self *FilesController) syncRefresh() error {
|
||||
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
|
||||
}
|
||||
|
||||
func (self *FilesController) refresh() error {
|
||||
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
}
|
||||
|
||||
func (self *FilesController) HandleCommitPress() error {
|
||||
if err := self.prepareFilesForCommit(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
if len(self.model.Files) == 0 {
|
||||
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.helpers.WorkingTree.AnyStagedFiles() {
|
||||
return self.promptToStageAllAndRetry(self.HandleCommitPress)
|
||||
}
|
||||
|
||||
savedCommitMessage := self.getSavedCommitMessage()
|
||||
if len(savedCommitMessage) > 0 {
|
||||
self.setCommitMessage(savedCommitMessage)
|
||||
} else {
|
||||
commitPrefixConfig := self.commitPrefixConfigForRepo()
|
||||
if commitPrefixConfig != nil {
|
||||
prefixPattern := commitPrefixConfig.Pattern
|
||||
prefixReplace := commitPrefixConfig.Replace
|
||||
rgx, err := regexp.Compile(prefixPattern)
|
||||
if err != nil {
|
||||
return self.c.ErrorMsg(fmt.Sprintf("%s: %s", self.c.Tr.LcCommitPrefixPatternError, err.Error()))
|
||||
}
|
||||
prefix := rgx.ReplaceAllString(self.helpers.Refs.GetCheckedOutRef().Name, prefixReplace)
|
||||
self.setCommitMessage(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.c.PushContext(self.contexts.CommitMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) promptToStageAllAndRetry(retry func() error) error {
|
||||
return self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.NoFilesStagedTitle,
|
||||
Prompt: self.c.Tr.NoFilesStagedPrompt,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
if err := self.git.WorkingTree.StageAll(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
if err := self.syncRefresh(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
return retry()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) handleAmendCommitPress() error {
|
||||
if len(self.model.Files) == 0 {
|
||||
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.helpers.WorkingTree.AnyStagedFiles() {
|
||||
return self.promptToStageAllAndRetry(self.handleAmendCommitPress)
|
||||
return self.helpers.WorkingTree.PromptToStageAllAndRetry(self.handleAmendCommitPress)
|
||||
}
|
||||
|
||||
if len(self.model.Commits) == 0 {
|
||||
@@ -677,23 +578,6 @@ func (self *FilesController) handleAmendCommitPress() error {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleCommitEditorPress - handle when the user wants to commit changes via
|
||||
// their editor rather than via the popup panel
|
||||
func (self *FilesController) HandleCommitEditorPress() error {
|
||||
if len(self.model.Files) == 0 {
|
||||
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.helpers.WorkingTree.AnyStagedFiles() {
|
||||
return self.promptToStageAllAndRetry(self.HandleCommitEditorPress)
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.Commit)
|
||||
return self.c.RunSubprocessAndRefresh(
|
||||
self.git.Commit.CommitEditorCmdObj(),
|
||||
)
|
||||
}
|
||||
|
||||
func (self *FilesController) handleStatusFilterPressed() error {
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.FilteringMenuTitle,
|
||||
|
||||
@@ -61,6 +61,8 @@ func (self *CredentialsHelper) getTitleAndMask(passOrUname oscommands.Credential
|
||||
return self.c.Tr.CredentialsPassword, true
|
||||
case oscommands.Passphrase:
|
||||
return self.c.Tr.CredentialsPassphrase, true
|
||||
case oscommands.PIN:
|
||||
return self.c.Tr.CredentialsPIN, true
|
||||
}
|
||||
|
||||
// should never land here
|
||||
|
||||
@@ -34,7 +34,21 @@ func NewFilesHelper(
|
||||
var _ IFilesHelper = &FilesHelper{}
|
||||
|
||||
func (self *FilesHelper) EditFile(filename string) error {
|
||||
return self.EditFileAtLine(filename, 1)
|
||||
editor, err := self.git.File.GetEditor()
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
editCmd := ""
|
||||
switch editor {
|
||||
case "code":
|
||||
editCmd = editor + " -r --goto -- " + filename
|
||||
default:
|
||||
editCmd = editor + " -- " + filename
|
||||
}
|
||||
self.c.LogAction(self.c.Tr.Actions.EditFile)
|
||||
return self.c.RunSubprocessAndRefresh(
|
||||
self.os.Cmd.NewShell(editCmd),
|
||||
)
|
||||
}
|
||||
|
||||
func (self *FilesHelper) EditFileAtLine(filename string, lineNumber int) error {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type IWorkingTreeHelper interface {
|
||||
@@ -14,17 +20,32 @@ type IWorkingTreeHelper interface {
|
||||
}
|
||||
|
||||
type WorkingTreeHelper struct {
|
||||
c *types.HelperCommon
|
||||
git *commands.GitCommand
|
||||
|
||||
model *types.Model
|
||||
c *types.HelperCommon
|
||||
git *commands.GitCommand
|
||||
contexts *context.ContextTree
|
||||
refHelper *RefsHelper
|
||||
model *types.Model
|
||||
setCommitMessage func(message string)
|
||||
getSavedCommitMessage func() string
|
||||
}
|
||||
|
||||
func NewWorkingTreeHelper(c *types.HelperCommon, git *commands.GitCommand, model *types.Model) *WorkingTreeHelper {
|
||||
func NewWorkingTreeHelper(
|
||||
c *types.HelperCommon,
|
||||
git *commands.GitCommand,
|
||||
contexts *context.ContextTree,
|
||||
refHelper *RefsHelper,
|
||||
model *types.Model,
|
||||
setCommitMessage func(message string),
|
||||
getSavedCommitMessage func() string,
|
||||
) *WorkingTreeHelper {
|
||||
return &WorkingTreeHelper{
|
||||
c: c,
|
||||
git: git,
|
||||
model: model,
|
||||
c: c,
|
||||
git: git,
|
||||
contexts: contexts,
|
||||
refHelper: refHelper,
|
||||
model: model,
|
||||
setCommitMessage: setCommitMessage,
|
||||
getSavedCommitMessage: getSavedCommitMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,3 +93,115 @@ func (self *WorkingTreeHelper) OpenMergeTool() error {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) HandleCommitPress() error {
|
||||
if err := self.prepareFilesForCommit(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
if len(self.model.Files) == 0 {
|
||||
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.AnyStagedFiles() {
|
||||
return self.PromptToStageAllAndRetry(self.HandleCommitPress)
|
||||
}
|
||||
|
||||
savedCommitMessage := self.getSavedCommitMessage()
|
||||
if len(savedCommitMessage) > 0 {
|
||||
self.setCommitMessage(savedCommitMessage)
|
||||
} else {
|
||||
commitPrefixConfig := self.commitPrefixConfigForRepo()
|
||||
if commitPrefixConfig != nil {
|
||||
prefixPattern := commitPrefixConfig.Pattern
|
||||
prefixReplace := commitPrefixConfig.Replace
|
||||
rgx, err := regexp.Compile(prefixPattern)
|
||||
if err != nil {
|
||||
return self.c.ErrorMsg(fmt.Sprintf("%s: %s", self.c.Tr.LcCommitPrefixPatternError, err.Error()))
|
||||
}
|
||||
prefix := rgx.ReplaceAllString(self.refHelper.GetCheckedOutRef().Name, prefixReplace)
|
||||
self.setCommitMessage(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.c.PushContext(self.contexts.CommitMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleCommitEditorPress - handle when the user wants to commit changes via
|
||||
// their editor rather than via the popup panel
|
||||
func (self *WorkingTreeHelper) HandleCommitEditorPress() error {
|
||||
if len(self.model.Files) == 0 {
|
||||
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.AnyStagedFiles() {
|
||||
return self.PromptToStageAllAndRetry(self.HandleCommitEditorPress)
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.Commit)
|
||||
return self.c.RunSubprocessAndRefresh(
|
||||
self.git.Commit.CommitEditorCmdObj(),
|
||||
)
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) HandleWIPCommitPress() error {
|
||||
skipHookPrefix := self.c.UserConfig.Git.SkipHookPrefix
|
||||
if skipHookPrefix == "" {
|
||||
return self.c.ErrorMsg(self.c.Tr.SkipHookPrefixNotConfigured)
|
||||
}
|
||||
|
||||
self.setCommitMessage(skipHookPrefix)
|
||||
|
||||
return self.HandleCommitPress()
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) PromptToStageAllAndRetry(retry func() error) error {
|
||||
return self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.NoFilesStagedTitle,
|
||||
Prompt: self.c.Tr.NoFilesStagedPrompt,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
if err := self.git.WorkingTree.StageAll(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
if err := self.syncRefresh(); err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
return retry()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// for when you need to refetch files before continuing an action. Runs synchronously.
|
||||
func (self *WorkingTreeHelper) syncRefresh() error {
|
||||
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) prepareFilesForCommit() error {
|
||||
noStagedFiles := !self.AnyStagedFiles()
|
||||
if noStagedFiles && self.c.UserConfig.Gui.SkipNoStagedFilesWarning {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
err := self.git.WorkingTree.StageAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.syncRefresh()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
|
||||
cfg, ok := self.c.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
@@ -226,6 +226,26 @@ func (self *LocalCommitsController) reword(commit *models.Commit) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) doRewordEditor() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RewordCommit)
|
||||
|
||||
if self.context().GetSelectedLineIdx() == 0 {
|
||||
return self.c.RunSubprocessAndRefresh(self.os.Cmd.New("git commit --allow-empty --amend --only"))
|
||||
}
|
||||
|
||||
subProcess, err := self.git.Rebase.RewordCommitInEditor(
|
||||
self.model.Commits, self.context().GetSelectedLineIdx(),
|
||||
)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
if subProcess != nil {
|
||||
return self.c.RunSubprocessAndRefresh(subProcess)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error {
|
||||
midRebase, err := self.handleMidRebaseCommand("reword", commit)
|
||||
if err != nil {
|
||||
@@ -235,29 +255,15 @@ func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.RewordInEditorTitle,
|
||||
Prompt: self.c.Tr.RewordInEditorPrompt,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RewordCommit)
|
||||
|
||||
if self.context().GetSelectedLineIdx() == 0 {
|
||||
return self.c.RunSubprocessAndRefresh(self.os.Cmd.New("git commit --allow-empty --amend --only"))
|
||||
}
|
||||
|
||||
subProcess, err := self.git.Rebase.RewordCommitInEditor(
|
||||
self.model.Commits, self.context().GetSelectedLineIdx(),
|
||||
)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
if subProcess != nil {
|
||||
return self.c.RunSubprocessAndRefresh(subProcess)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if self.c.UserConfig.Gui.SkipRewordInEditorWarning {
|
||||
return self.doRewordEditor()
|
||||
} else {
|
||||
return self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.RewordInEditorTitle,
|
||||
Prompt: self.c.Tr.RewordInEditorPrompt,
|
||||
HandleConfirm: self.doRewordEditor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) drop(commit *models.Commit) error {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/snake"
|
||||
)
|
||||
|
||||
type SnakeController struct {
|
||||
baseController
|
||||
*controllerCommon
|
||||
|
||||
getGame func() *snake.Game
|
||||
}
|
||||
|
||||
var _ types.IController = &SnakeController{}
|
||||
|
||||
func NewSnakeController(
|
||||
common *controllerCommon,
|
||||
getGame func() *snake.Game,
|
||||
) *SnakeController {
|
||||
return &SnakeController{
|
||||
baseController: baseController{},
|
||||
controllerCommon: common,
|
||||
getGame: getGame,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SnakeController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
bindings := []*types.Binding{
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Universal.NextItem),
|
||||
Handler: self.SetDirection(snake.Down),
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Universal.PrevItem),
|
||||
Handler: self.SetDirection(snake.Up),
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Universal.PrevBlock),
|
||||
Handler: self.SetDirection(snake.Left),
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Universal.NextBlock),
|
||||
Handler: self.SetDirection(snake.Right),
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Universal.Return),
|
||||
Handler: self.Escape,
|
||||
},
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (self *SnakeController) Context() types.Context {
|
||||
return self.contexts.Snake
|
||||
}
|
||||
|
||||
func (self *SnakeController) SetDirection(direction snake.Direction) func() error {
|
||||
return func() error {
|
||||
self.getGame().SetDirection(direction)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SnakeController) Escape() error {
|
||||
return self.c.PushContext(self.contexts.Submodules)
|
||||
}
|
||||
@@ -73,6 +73,21 @@ func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*typ
|
||||
Handler: self.EditHunkAndRefresh,
|
||||
Description: self.c.Tr.EditHunk,
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChanges),
|
||||
Handler: self.helpers.WorkingTree.HandleCommitPress,
|
||||
Description: self.c.Tr.CommitChanges,
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook),
|
||||
Handler: self.helpers.WorkingTree.HandleWIPCommitPress,
|
||||
Description: self.c.Tr.LcCommitChangesWithoutHook,
|
||||
},
|
||||
{
|
||||
Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor),
|
||||
Handler: self.helpers.WorkingTree.HandleCommitEditorPress,
|
||||
Description: self.c.Tr.CommitChangesWithEditor,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*
|
||||
Description: self.c.Tr.LcViewBulkSubmoduleOptions,
|
||||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
Key: nil,
|
||||
Handler: self.easterEgg,
|
||||
Description: self.c.Tr.EasterEgg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +224,10 @@ func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) erro
|
||||
})
|
||||
}
|
||||
|
||||
func (self *SubmodulesController) easterEgg() error {
|
||||
return self.c.PushContext(self.contexts.Snake)
|
||||
}
|
||||
|
||||
func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error {
|
||||
return func() error {
|
||||
submodule := self.context().GetSelected()
|
||||
|
||||
@@ -3,23 +3,29 @@ package controllers
|
||||
import (
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
)
|
||||
|
||||
// given we have no fields here, arguably we shouldn't even need this factory
|
||||
// struct, but we're maintaining consistency with the other files.
|
||||
type VerticalScrollControllerFactory struct {
|
||||
controllerCommon *controllerCommon
|
||||
controllerCommon *controllerCommon
|
||||
viewBufferManagerMap *map[string]*tasks.ViewBufferManager
|
||||
}
|
||||
|
||||
func NewVerticalScrollControllerFactory(c *controllerCommon) *VerticalScrollControllerFactory {
|
||||
return &VerticalScrollControllerFactory{controllerCommon: c}
|
||||
func NewVerticalScrollControllerFactory(c *controllerCommon, viewBufferManagerMap *map[string]*tasks.ViewBufferManager) *VerticalScrollControllerFactory {
|
||||
return &VerticalScrollControllerFactory{
|
||||
controllerCommon: c,
|
||||
viewBufferManagerMap: viewBufferManagerMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *VerticalScrollControllerFactory) Create(context types.Context) types.IController {
|
||||
return &VerticalScrollController{
|
||||
baseController: baseController{},
|
||||
controllerCommon: self.controllerCommon,
|
||||
context: context,
|
||||
baseController: baseController{},
|
||||
controllerCommon: self.controllerCommon,
|
||||
context: context,
|
||||
viewBufferManagerMap: self.viewBufferManagerMap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +33,8 @@ type VerticalScrollController struct {
|
||||
baseController
|
||||
*controllerCommon
|
||||
|
||||
context types.Context
|
||||
context types.Context
|
||||
viewBufferManagerMap *map[string]*tasks.ViewBufferManager
|
||||
}
|
||||
|
||||
func (self *VerticalScrollController) Context() types.Context {
|
||||
@@ -64,7 +71,12 @@ func (self *VerticalScrollController) HandleScrollUp() error {
|
||||
}
|
||||
|
||||
func (self *VerticalScrollController) HandleScrollDown() error {
|
||||
self.context.GetViewTrait().ScrollDown(self.c.UserConfig.Gui.ScrollHeight)
|
||||
scrollHeight := self.c.UserConfig.Gui.ScrollHeight
|
||||
self.context.GetViewTrait().ScrollDown(scrollHeight)
|
||||
|
||||
if manager, ok := (*self.viewBufferManagerMap)[self.context.GetViewName()]; ok {
|
||||
manager.ReadLines(scrollHeight)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,14 @@ func (gui *Gui) handleCreatePatchOptionsMenu() error {
|
||||
}
|
||||
}
|
||||
|
||||
menuItems = append(menuItems, []*types.MenuItem{
|
||||
{
|
||||
Label: "copy patch to clipboard",
|
||||
OnPress: func() error { return gui.copyPatchToClipboard() },
|
||||
Key: 'y',
|
||||
},
|
||||
}...)
|
||||
|
||||
return gui.c.Menu(types.CreateMenuOptions{Title: gui.c.Tr.PatchOptionsTitle, Items: menuItems})
|
||||
}
|
||||
|
||||
@@ -192,3 +200,16 @@ func (gui *Gui) handleApplyPatch(reverse bool) error {
|
||||
}
|
||||
return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
}
|
||||
|
||||
func (gui *Gui) copyPatchToClipboard() error {
|
||||
patch := gui.git.Patch.PatchManager.RenderAggregatedPatchColored(true)
|
||||
|
||||
gui.c.LogAction(gui.c.Tr.Actions.CopyPatchToClipboard)
|
||||
if err := gui.os.CopyToClipboard(patch); err != nil {
|
||||
return gui.c.Error(err)
|
||||
}
|
||||
|
||||
gui.c.Toast(gui.c.Tr.PatchCopiedToClipboard)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/updates"
|
||||
@@ -17,6 +17,6 @@ func NewDummyUpdater() *updates.Updater {
|
||||
|
||||
func NewDummyGui() *Gui {
|
||||
newAppConfig := config.NewDummyAppConfig()
|
||||
dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, git_config.NewFakeGitConfig(nil), NewDummyUpdater(), false, "")
|
||||
dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{}, NewDummyUpdater(), false, "")
|
||||
return dummyGui
|
||||
}
|
||||
|
||||
+25
-67
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
|
||||
@@ -32,7 +31,9 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/snake"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/updates"
|
||||
@@ -75,9 +76,10 @@ type Repo string
|
||||
// Gui wraps the gocui Gui object which handles rendering and events
|
||||
type Gui struct {
|
||||
*common.Common
|
||||
g *gocui.Gui
|
||||
git *commands.GitCommand
|
||||
os *oscommands.OSCommand
|
||||
g *gocui.Gui
|
||||
gitVersion *git_commands.GitVersion
|
||||
git *commands.GitCommand
|
||||
os *oscommands.OSCommand
|
||||
|
||||
// this is the state of the GUI for the current repo
|
||||
State *GuiRepoState
|
||||
@@ -154,6 +156,8 @@ type Gui struct {
|
||||
|
||||
c *types.HelperCommon
|
||||
helpers *helpers.Helpers
|
||||
|
||||
snakeGame *snake.Game
|
||||
}
|
||||
|
||||
// we keep track of some stuff from one render to the next to see if certain
|
||||
@@ -219,6 +223,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
|
||||
var err error
|
||||
gui.git, err = commands.NewGitCommand(
|
||||
gui.Common,
|
||||
gui.gitVersion,
|
||||
gui.os,
|
||||
git_config.NewStdCachedGitConfig(gui.Log),
|
||||
gui.Mutexes.SyncMutex,
|
||||
@@ -272,7 +277,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) {
|
||||
contextTree := gui.contextTree()
|
||||
|
||||
initialContext := initialContext(contextTree, startArgs)
|
||||
initialScreenMode := initialScreenMode(startArgs)
|
||||
initialScreenMode := initialScreenMode(startArgs, gui.Config)
|
||||
|
||||
initialWindowViewNameMap := gui.initialWindowViewNameMap(contextTree)
|
||||
|
||||
@@ -302,11 +307,20 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) {
|
||||
gui.RepoStateMap[Repo(currentDir)] = gui.State
|
||||
}
|
||||
|
||||
func initialScreenMode(startArgs appTypes.StartArgs) WindowMaximisation {
|
||||
func initialScreenMode(startArgs appTypes.StartArgs, config config.AppConfigurer) WindowMaximisation {
|
||||
if startArgs.FilterPath != "" || startArgs.GitArg != appTypes.GitArgNone {
|
||||
return SCREEN_HALF
|
||||
} else {
|
||||
return SCREEN_NORMAL
|
||||
defaultWindowSize := config.GetUserConfig().Gui.WindowSize
|
||||
|
||||
switch defaultWindowSize {
|
||||
case "half":
|
||||
return SCREEN_HALF
|
||||
case "full":
|
||||
return SCREEN_FULL
|
||||
default:
|
||||
return SCREEN_NORMAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,13 +352,14 @@ func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartAr
|
||||
func NewGui(
|
||||
cmn *common.Common,
|
||||
config config.AppConfigurer,
|
||||
gitConfig git_config.IGitConfig,
|
||||
gitVersion *git_commands.GitVersion,
|
||||
updater *updates.Updater,
|
||||
showRecentRepos bool,
|
||||
initialDir string,
|
||||
) (*Gui, error) {
|
||||
gui := &Gui{
|
||||
Common: cmn,
|
||||
gitVersion: gitVersion,
|
||||
Config: config,
|
||||
Updater: updater,
|
||||
statusManager: &statusManager{},
|
||||
@@ -426,7 +441,7 @@ func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest)
|
||||
playMode = gocui.RECORDING
|
||||
} else if Replaying() {
|
||||
playMode = gocui.REPLAYING
|
||||
} else if test != nil {
|
||||
} else if test != nil && os.Getenv(components.SANDBOX_ENV_VAR) != "true" {
|
||||
playMode = gocui.REPLAYING_NEW
|
||||
}
|
||||
|
||||
@@ -526,27 +541,7 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
gui.waitForIntro.Add(1)
|
||||
|
||||
if userConfig.Git.AutoFetch {
|
||||
fetchInterval := userConfig.Refresher.FetchInterval
|
||||
if fetchInterval > 0 {
|
||||
go utils.Safe(gui.startBackgroundFetch)
|
||||
} else {
|
||||
gui.c.Log.Errorf(
|
||||
"Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch",
|
||||
fetchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if userConfig.Git.AutoRefresh {
|
||||
refreshInterval := userConfig.Refresher.RefreshInterval
|
||||
if refreshInterval > 0 {
|
||||
gui.goEvery(time.Second*time.Duration(refreshInterval), gui.stopChan, gui.refreshFilesAndSubmodules)
|
||||
} else {
|
||||
gui.c.Log.Errorf(
|
||||
"Value of config option 'refresher.refreshInterval' (%d) is invalid, disabling auto-refresh",
|
||||
refreshInterval)
|
||||
}
|
||||
}
|
||||
gui.startBackgroundRoutines()
|
||||
|
||||
gui.c.Log.Info("starting main loop")
|
||||
|
||||
@@ -721,43 +716,6 @@ func (gui *Gui) showIntroPopupMessage(done chan struct{}) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) goEvery(interval time.Duration, stop chan struct{}, function func() error) {
|
||||
go utils.Safe(func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if gui.PauseBackgroundThreads {
|
||||
continue
|
||||
}
|
||||
_ = function()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) startBackgroundFetch() {
|
||||
gui.waitForIntro.Wait()
|
||||
isNew := gui.IsNewRepo
|
||||
userConfig := gui.UserConfig
|
||||
if !isNew {
|
||||
time.After(time.Duration(userConfig.Refresher.FetchInterval) * time.Second)
|
||||
}
|
||||
err := gui.backgroundFetch()
|
||||
if err != nil && strings.Contains(err.Error(), "exit status 128") && isNew {
|
||||
_ = gui.c.Alert(gui.c.Tr.NoAutomaticGitFetchTitle, gui.c.Tr.NoAutomaticGitFetchBody)
|
||||
} else {
|
||||
gui.goEvery(time.Second*time.Duration(userConfig.Refresher.FetchInterval), gui.stopChan, func() error {
|
||||
err := gui.backgroundFetch()
|
||||
gui.render()
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// setColorScheme sets the color scheme for the app based on the user config
|
||||
func (gui *Gui) setColorScheme() error {
|
||||
userConfig := gui.UserConfig
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
@@ -47,15 +49,11 @@ func (self *GuiDriver) CurrentContext() types.Context {
|
||||
return self.gui.c.CurrentContext()
|
||||
}
|
||||
|
||||
func (self *GuiDriver) Model() *types.Model {
|
||||
return self.gui.State.Model
|
||||
}
|
||||
|
||||
func (self *GuiDriver) Fail(message string) {
|
||||
self.gui.g.Close()
|
||||
// need to give the gui time to close
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
panic(message)
|
||||
panic(fmt.Sprintf("%s\nLog:\n%s", message, strings.Join(self.gui.CmdLog, "\n")))
|
||||
}
|
||||
|
||||
// logs to the normal place that you log to i.e. viewable with `lazygit --logs`
|
||||
|
||||
@@ -74,6 +74,12 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: self.handleTopLevelReturn,
|
||||
},
|
||||
{
|
||||
ViewName: "",
|
||||
Key: opts.GetKey(opts.Config.Universal.ReturnAlt1),
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: self.handleTopLevelReturn,
|
||||
},
|
||||
{
|
||||
ViewName: "",
|
||||
Key: opts.GetKey(opts.Config.Universal.OpenRecentRepos),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
gctx "github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -16,7 +17,7 @@ type PopupHandler struct {
|
||||
*common.Common
|
||||
index int
|
||||
deadlock.Mutex
|
||||
createPopupPanelFn func(types.CreatePopupPanelOpts) error
|
||||
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) error
|
||||
onErrorFn func() error
|
||||
popContextFn func() error
|
||||
currentContextFn func() types.Context
|
||||
@@ -30,7 +31,7 @@ var _ types.IPopupHandler = &PopupHandler{}
|
||||
|
||||
func NewPopupHandler(
|
||||
common *common.Common,
|
||||
createPopupPanelFn func(types.CreatePopupPanelOpts) error,
|
||||
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) error,
|
||||
onErrorFn func() error,
|
||||
popContextFn func() error,
|
||||
currentContextFn func() types.Context,
|
||||
@@ -96,7 +97,7 @@ func (self *PopupHandler) Confirm(opts types.ConfirmOpts) error {
|
||||
self.index++
|
||||
self.Unlock()
|
||||
|
||||
return self.createPopupPanelFn(types.CreatePopupPanelOpts{
|
||||
return self.createPopupPanelFn(context.Background(), types.CreatePopupPanelOpts{
|
||||
Title: opts.Title,
|
||||
Prompt: opts.Prompt,
|
||||
HandleConfirm: opts.HandleConfirm,
|
||||
@@ -109,7 +110,7 @@ func (self *PopupHandler) Prompt(opts types.PromptOpts) error {
|
||||
self.index++
|
||||
self.Unlock()
|
||||
|
||||
return self.createPopupPanelFn(types.CreatePopupPanelOpts{
|
||||
return self.createPopupPanelFn(context.Background(), types.CreatePopupPanelOpts{
|
||||
Title: opts.Title,
|
||||
Prompt: opts.InitialContent,
|
||||
Editable: true,
|
||||
@@ -127,12 +128,15 @@ func (self *PopupHandler) WithLoaderPanel(message string, f func() error) error
|
||||
index = self.index
|
||||
self.Unlock()
|
||||
|
||||
err := self.createPopupPanelFn(types.CreatePopupPanelOpts{
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
err := self.createPopupPanelFn(ctx, types.CreatePopupPanelOpts{
|
||||
Prompt: message,
|
||||
HasLoader: true,
|
||||
})
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -141,8 +145,10 @@ func (self *PopupHandler) WithLoaderPanel(message string, f func() error) error
|
||||
self.Log.Error(err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
self.Lock()
|
||||
if index == self.index && self.currentContextFn().GetKey() == context.CONFIRMATION_CONTEXT_KEY {
|
||||
if index == self.index && self.currentContextFn().GetKey() == gctx.CONFIRMATION_CONTEXT_KEY {
|
||||
_ = self.popContextFn()
|
||||
}
|
||||
self.Unlock()
|
||||
|
||||
@@ -67,26 +67,38 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro
|
||||
}
|
||||
|
||||
resolveTemplate := self.getResolveTemplateFn(form, promptResponses, sessionState)
|
||||
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
switch prompt.Type {
|
||||
case "input":
|
||||
f = func() error {
|
||||
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
return self.inputPrompt(resolvedPrompt, wrappedF)
|
||||
}
|
||||
case "menu":
|
||||
f = func() error {
|
||||
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
return self.menuPrompt(resolvedPrompt, wrappedF)
|
||||
}
|
||||
case "menuFromCommand":
|
||||
f = func() error {
|
||||
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
return self.menuPromptFromCommand(resolvedPrompt, wrappedF)
|
||||
}
|
||||
case "confirm":
|
||||
f = func() error {
|
||||
resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
return self.confirmPrompt(resolvedPrompt, g)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/snake"
|
||||
)
|
||||
|
||||
func (gui *Gui) startSnake() {
|
||||
view := gui.Views.Snake
|
||||
|
||||
game := snake.NewGame(view.Width(), view.Height(), gui.renderSnakeGame, gui.c.LogAction)
|
||||
gui.snakeGame = game
|
||||
game.Start()
|
||||
}
|
||||
|
||||
func (gui *Gui) renderSnakeGame(cells [][]snake.CellType, alive bool) {
|
||||
view := gui.Views.Snake
|
||||
|
||||
if !alive {
|
||||
_ = gui.c.ErrorMsg(gui.Tr.YouDied)
|
||||
return
|
||||
}
|
||||
|
||||
output := drawSnakeGame(cells)
|
||||
|
||||
view.Clear()
|
||||
fmt.Fprint(view, output)
|
||||
gui.c.Render()
|
||||
}
|
||||
|
||||
func drawSnakeGame(cells [][]snake.CellType) string {
|
||||
writer := &strings.Builder{}
|
||||
|
||||
for i, row := range cells {
|
||||
for _, cell := range row {
|
||||
switch cell {
|
||||
case snake.None:
|
||||
writer.WriteString(" ")
|
||||
case snake.Snake:
|
||||
writer.WriteString("█")
|
||||
case snake.Food:
|
||||
writer.WriteString(style.FgMagenta.Sprint("█"))
|
||||
}
|
||||
}
|
||||
|
||||
if i < len(cells) {
|
||||
writer.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
output := writer.String()
|
||||
return output
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -17,6 +18,10 @@ type IntegrationTest interface {
|
||||
}
|
||||
|
||||
func (gui *Gui) handleTestMode(test integrationTypes.IntegrationTest) {
|
||||
if os.Getenv(components.SANDBOX_ENV_VAR) == "true" {
|
||||
return
|
||||
}
|
||||
|
||||
if test != nil {
|
||||
go func() {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
@@ -40,6 +40,9 @@ type Views struct {
|
||||
Suggestions *gocui.View
|
||||
Tooltip *gocui.View
|
||||
Extras *gocui.View
|
||||
|
||||
// for playing the easter egg snake game
|
||||
Snake *gocui.View
|
||||
}
|
||||
|
||||
type viewNameMapping struct {
|
||||
@@ -58,6 +61,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
|
||||
// first layer. Ordering within this layer does not matter because there are
|
||||
// no overlapping views
|
||||
{viewPtr: &gui.Views.Status, name: "status"},
|
||||
{viewPtr: &gui.Views.Snake, name: "snake"},
|
||||
{viewPtr: &gui.Views.Submodules, name: "submodules"},
|
||||
{viewPtr: &gui.Views.Files, name: "files"},
|
||||
{viewPtr: &gui.Views.Tags, name: "tags"},
|
||||
@@ -220,5 +224,8 @@ func (gui *Gui) createAllViews() error {
|
||||
gui.Views.Extras.Autoscroll = true
|
||||
gui.Views.Extras.Wrap = true
|
||||
|
||||
gui.Views.Snake.Title = gui.c.Tr.SnakeTitle
|
||||
gui.Views.Snake.FgColor = gocui.ColorGreen
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+4
-9
@@ -76,26 +76,21 @@ func (gui *Gui) resetWindowContext(c types.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// moves given context's view to the top of the window and returns
|
||||
// true if the view was not already on top.
|
||||
func (gui *Gui) moveToTopOfWindow(context types.Context) bool {
|
||||
// moves given context's view to the top of the window
|
||||
func (gui *Gui) moveToTopOfWindow(context types.Context) {
|
||||
view := context.GetView()
|
||||
if view == nil {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
window := context.GetWindowName()
|
||||
|
||||
topView := gui.topViewInWindow(window)
|
||||
|
||||
if view.Name() == topView.Name() {
|
||||
return false
|
||||
} else {
|
||||
if view.Name() != topView.Name() {
|
||||
if err := gui.g.SetViewOnTopOf(view.Name(), topView.Name()); err != nil {
|
||||
gui.Log.Error(err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,6 @@ func chineseTranslationSet() TranslationSet {
|
||||
MergeConflictsTitle: "合并冲突",
|
||||
LcCheckout: "检出",
|
||||
NoChangedFiles: "没有更改过文件",
|
||||
NoFilesDisplay: "没有文件可显示",
|
||||
NotAFile: "不是文件",
|
||||
PullWait: "正在拉取…",
|
||||
PushWait: "正在推送…",
|
||||
FetchWait: "正在抓取…",
|
||||
@@ -104,7 +102,6 @@ func chineseTranslationSet() TranslationSet {
|
||||
LcSquashDown: "向下压缩",
|
||||
LcFixupCommit: "修正提交(fixup)",
|
||||
NoCommitsThisBranch: "该分支没有提交",
|
||||
OnlySquashTopmostCommit: "只能压缩最顶层的提交",
|
||||
YouNoCommitsToSquash: "您没有提交可以压缩",
|
||||
Fixup: "修正(fixup)",
|
||||
SureFixupThisCommit: "您确定要“修正”此提交吗?它将合并到下面的提交中",
|
||||
|
||||
@@ -39,8 +39,6 @@ func dutchTranslationSet() TranslationSet {
|
||||
ResetCommitFilterState: "Reset commit file state filter",
|
||||
MergeConflictsTitle: "Merge Conflicten",
|
||||
LcCheckout: "uitchecken",
|
||||
NoFilesDisplay: "Geen bestanden om te laten zien",
|
||||
NotAFile: "Dit is geen bestand",
|
||||
PullWait: "Pullen...",
|
||||
PushWait: "Pushen...",
|
||||
FetchWait: "Fetchen...",
|
||||
@@ -69,7 +67,6 @@ func dutchTranslationSet() TranslationSet {
|
||||
LcQuit: "quit",
|
||||
LcSquashDown: "squash beneden",
|
||||
LcFixupCommit: "Fixup commit",
|
||||
OnlySquashTopmostCommit: "Kan alleen bovenste commit squashen",
|
||||
YouNoCommitsToSquash: "Je hebt geen commits om mee te squashen",
|
||||
Fixup: "Fixup",
|
||||
SureFixupThisCommit: "Weet je zeker dat je fixup wil uitvoeren op deze commit? De commit hieronder zol worden squashed in deze",
|
||||
|
||||
+13
-7
@@ -17,6 +17,8 @@ type TranslationSet struct {
|
||||
BranchesTitle string
|
||||
CommitsTitle string
|
||||
StashTitle string
|
||||
SnakeTitle string
|
||||
EasterEgg string
|
||||
UnstagedChanges string
|
||||
StagedChanges string
|
||||
MainTitle string
|
||||
@@ -29,6 +31,7 @@ type TranslationSet struct {
|
||||
CredentialsUsername string
|
||||
CredentialsPassword string
|
||||
CredentialsPassphrase string
|
||||
CredentialsPIN string
|
||||
PassUnameWrong string
|
||||
CommitChanges string
|
||||
AmendLastCommit string
|
||||
@@ -56,8 +59,6 @@ type TranslationSet struct {
|
||||
MergeConflictsTitle string
|
||||
LcCheckout string
|
||||
NoChangedFiles string
|
||||
NoFilesDisplay string
|
||||
NotAFile string
|
||||
PullWait string
|
||||
PushWait string
|
||||
FetchWait string
|
||||
@@ -86,7 +87,6 @@ type TranslationSet struct {
|
||||
LcQuit string
|
||||
LcSquashDown string
|
||||
LcFixupCommit string
|
||||
OnlySquashTopmostCommit string
|
||||
YouNoCommitsToSquash string
|
||||
Fixup string
|
||||
SureFixupThisCommit string
|
||||
@@ -212,6 +212,7 @@ type TranslationSet struct {
|
||||
ErrorOccurred string
|
||||
NoRoom string
|
||||
YouAreHere string
|
||||
YouDied string
|
||||
LcRewordNotSupported string
|
||||
LcCherryPickCopy string
|
||||
LcCherryPickCopyRange string
|
||||
@@ -462,6 +463,7 @@ type TranslationSet struct {
|
||||
CommitURLCopiedToClipboard string
|
||||
CommitMessageCopiedToClipboard string
|
||||
CommitAuthorCopiedToClipboard string
|
||||
PatchCopiedToClipboard string
|
||||
LcCopiedToClipboard string
|
||||
ErrCannotEditDirectory string
|
||||
ErrStageDirWithInlineMergeConflicts string
|
||||
@@ -565,6 +567,7 @@ type Actions struct {
|
||||
CopyCommitURLToClipboard string
|
||||
CopyCommitAuthorToClipboard string
|
||||
CopyCommitAttributeToClipboard string
|
||||
CopyPatchToClipboard string
|
||||
CustomCommand string
|
||||
DiscardAllChangesInDirectory string
|
||||
DiscardUnstagedChangesInDirectory string
|
||||
@@ -664,6 +667,8 @@ func EnglishTranslationSet() TranslationSet {
|
||||
BranchesTitle: "Branches",
|
||||
CommitsTitle: "Commits",
|
||||
StashTitle: "Stash",
|
||||
SnakeTitle: "Snake",
|
||||
EasterEgg: "easter egg",
|
||||
UnstagedChanges: `Unstaged Changes`,
|
||||
StagedChanges: `Staged Changes`,
|
||||
MainTitle: "Main",
|
||||
@@ -676,6 +681,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
CredentialsUsername: "Username",
|
||||
CredentialsPassword: "Password",
|
||||
CredentialsPassphrase: "Enter passphrase for SSH key",
|
||||
CredentialsPIN: "Enter PIN for SSH key",
|
||||
PassUnameWrong: "Password, passphrase and/or username wrong",
|
||||
CommitChanges: "commit changes",
|
||||
AmendLastCommit: "amend last commit",
|
||||
@@ -702,8 +708,6 @@ func EnglishTranslationSet() TranslationSet {
|
||||
FilterUnstagedFiles: "Show only unstaged files",
|
||||
ResetCommitFilterState: "Reset filter",
|
||||
NoChangedFiles: "No changed files",
|
||||
NoFilesDisplay: "No file to display",
|
||||
NotAFile: "Not a file",
|
||||
PullWait: "Pulling...",
|
||||
PushWait: "Pushing...",
|
||||
FetchWait: "Fetching...",
|
||||
@@ -733,7 +737,6 @@ func EnglishTranslationSet() TranslationSet {
|
||||
LcSquashDown: "squash down",
|
||||
LcFixupCommit: "fixup commit",
|
||||
NoCommitsThisBranch: "No commits for this branch",
|
||||
OnlySquashTopmostCommit: "Can only squash topmost commit",
|
||||
YouNoCommitsToSquash: "You have no commits to squash with",
|
||||
Fixup: "Fixup",
|
||||
SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below",
|
||||
@@ -851,7 +854,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
GlobalTitle: "Global Keybindings",
|
||||
ConflictsResolved: "all merge conflicts resolved. Continue?",
|
||||
RebasingTitle: "Rebasing",
|
||||
ConfirmRebase: "Are you sure you want to rebase '{{.checkedOutBranch}}' onto '{{.selectedBranch}}'?",
|
||||
ConfirmRebase: "Are you sure you want to rebase '{{.checkedOutBranch}}' on top of '{{.selectedBranch}}'?",
|
||||
ConfirmMerge: "Are you sure you want to merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'?",
|
||||
FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
|
||||
FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
|
||||
@@ -859,6 +862,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
ErrorOccurred: "An error occurred! Please create an issue at",
|
||||
NoRoom: "Not enough room",
|
||||
YouAreHere: "YOU ARE HERE",
|
||||
YouDied: "YOU DIED!",
|
||||
LcRewordNotSupported: "rewording commits while interactively rebasing is not currently supported",
|
||||
LcCherryPickCopy: "copy commit (cherry-pick)",
|
||||
LcCherryPickCopyRange: "copy commit range (cherry-pick)",
|
||||
@@ -1109,6 +1113,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
CommitURLCopiedToClipboard: "Commit URL copied to clipboard",
|
||||
CommitMessageCopiedToClipboard: "Commit message copied to clipboard",
|
||||
CommitAuthorCopiedToClipboard: "Commit author copied to clipboard",
|
||||
PatchCopiedToClipboard: "Patch copied to clipboard",
|
||||
LcCopiedToClipboard: "copied to clipboard",
|
||||
ErrCannotEditDirectory: "Cannot edit directory: you can only edit individual files",
|
||||
ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first",
|
||||
@@ -1193,6 +1198,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
CopyCommitURLToClipboard: "Copy commit URL to clipboard",
|
||||
CopyCommitAuthorToClipboard: "Copy commit author to clipboard",
|
||||
CopyCommitAttributeToClipboard: "Copy to clipboard",
|
||||
CopyPatchToClipboard: "Copy patch to clipboard",
|
||||
MoveCommitUp: "Move commit up",
|
||||
MoveCommitDown: "Move commit down",
|
||||
CustomCommand: "Custom command",
|
||||
|
||||
@@ -64,8 +64,6 @@ func japaneseTranslationSet() TranslationSet {
|
||||
FilterUnstagedFiles: "ステージされていないファイルのみを表示",
|
||||
ResetCommitFilterState: "フィルタをリセット",
|
||||
// NoChangedFiles: "No changed files",
|
||||
// NoFilesDisplay: "No file to display",
|
||||
// NotAFile: "Not a file",
|
||||
PullWait: "Pull中...",
|
||||
PushWait: "Push中...",
|
||||
FetchWait: "Fetch中...",
|
||||
@@ -95,7 +93,6 @@ func japaneseTranslationSet() TranslationSet {
|
||||
// LcSquashDown: "squash down",
|
||||
// LcFixupCommit: "fixup commit",
|
||||
// NoCommitsThisBranch: "No commits for this branch",
|
||||
// OnlySquashTopmostCommit: "Can only squash topmost commit",
|
||||
// YouNoCommitsToSquash: "You have no commits to squash with",
|
||||
// Fixup: "Fixup",
|
||||
// SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below",
|
||||
|
||||
@@ -63,8 +63,6 @@ func koreanTranslationSet() TranslationSet {
|
||||
FilterUnstagedFiles: "Stage되지 않은 파일만 표시",
|
||||
ResetCommitFilterState: "필터 리셋",
|
||||
NoChangedFiles: "변경된 파일이 없습니다.",
|
||||
NoFilesDisplay: "표시할 파일이 없습니다",
|
||||
NotAFile: "파일이 아닙니다.",
|
||||
PullWait: "업데이트 중...",
|
||||
PushWait: "푸시 중...",
|
||||
FetchWait: "패치 중...",
|
||||
@@ -94,7 +92,6 @@ func koreanTranslationSet() TranslationSet {
|
||||
LcSquashDown: "squash down",
|
||||
LcFixupCommit: "fixup commit",
|
||||
NoCommitsThisBranch: "이 브랜치에 커밋이 없습니다.",
|
||||
OnlySquashTopmostCommit: "Can only squash topmost commit",
|
||||
YouNoCommitsToSquash: "You have no commits to squash with",
|
||||
Fixup: "Fixup",
|
||||
SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below",
|
||||
|
||||
@@ -35,7 +35,6 @@ func polishTranslationSet() TranslationSet {
|
||||
ResetCommitFilterState: "Resetuj filtr commitów",
|
||||
LcCheckout: "przełącz",
|
||||
NoChangedFiles: "Brak zmienionych plików",
|
||||
NoFilesDisplay: "Brak plików do wyświetlenia",
|
||||
PullWait: "Pobieranie zmian...",
|
||||
PushWait: "Wysyłanie zmian...",
|
||||
FetchWait: "Pobieram...",
|
||||
@@ -63,7 +62,6 @@ func polishTranslationSet() TranslationSet {
|
||||
LcSquashDown: "ściśnij",
|
||||
LcFixupCommit: "napraw commit",
|
||||
NoCommitsThisBranch: "Brak commitów dla tej gałęzi",
|
||||
OnlySquashTopmostCommit: "Można tylko spłaszczyć najwyższy commit",
|
||||
YouNoCommitsToSquash: "Nie masz commitów do spłaszczenia",
|
||||
Fixup: "Napraw",
|
||||
SureFixupThisCommit: "Jesteś pewny, ze chcesz naprawić ten commit? Commit poniżej zostanie spłaszczony w górę wraz z tym",
|
||||
|
||||
+19
-20
@@ -24,26 +24,25 @@ In the setup step, we prepare a repo with shell commands, for example, creating
|
||||
|
||||
### Run step
|
||||
|
||||
The run step has four arguments passed in:
|
||||
The run step has two arguments passed in:
|
||||
|
||||
1. `shell`
|
||||
2. `input`
|
||||
3. `assert`
|
||||
4. `keys`
|
||||
1. `t` (the test driver)
|
||||
2. `keys`
|
||||
|
||||
`shell` we've already seen in the setup step. The reason it's passed into the run step is that we may want to emulate background events. For example, the user modifying a file outside of lazygit.
|
||||
|
||||
`input` is for driving the gui by pressing certain keys, selecting list items, etc.
|
||||
|
||||
`assert` is for asserting on the state of the lazygit session. When you call a method on `assert`, the assert struct will wait for the assertion to hold true and then continue (failing the test after a timeout). For this reason, assertions have two purposes: one is to ensure the test fails as soon as something unexpected happens, but another is to allow lazygit to process a keypress before you follow up with more keypresses. If you input a bunch of keypresses too quickly lazygit might get confused.
|
||||
`t` is for driving the gui by pressing certain keys, selecting list items, etc.
|
||||
`keys` is for use when getting the test to press a particular key e.g. `t.Views().Commits().Focus().PressKey(keys.Universal.Confirm)`
|
||||
|
||||
### Tips
|
||||
|
||||
#### Handle most setup in the `shell` part of the test
|
||||
|
||||
Try to do as much setup work as possible in your setup step. For example, if all you're testing is that the user is able to resolve merge conflicts, create the merge conflicts in the setup step. On the other hand, if you're testing to see that lazygit can warn the user about merge conflicts after an attempted merge, it's fine to wait until the run step to actually create the conflicts. If the run step is focused on the thing you're trying to test, the test will run faster and its intent will be clearer.
|
||||
|
||||
Use assertions to ensure that lazygit has processed all your keybindings so far. For example, if you press 'n' on a branch to create a new branch, assert that the confirmation view is now focused.
|
||||
#### Create helper functions for (very) frequently used test logic
|
||||
|
||||
If you find yourself doing something frequently in a test, consider making it a method in one of the helper arguments. For example, instead of calling `input.PressKey(keys.Universal.Confirm)` in 100 places, it's better to have a method `input.Confirm()`. This is not to say that everything should be made into a method on the input struct: just things that are particularly common in tests.
|
||||
If you find yourself doing something frequently in a test, consider making it a method in one of the helper arguments. For example, instead of calling `t.PressKey(keys.Universal.Confirm)` in 100 places, it's better to have a method `t.Confirm()`. This is not to say that everything should be made into a helper method: just things that are particularly common in tests.
|
||||
|
||||
Also, given how often we need to select a menu item or type into a prompt panel, there are some helper functions for that. See `ExpectConfirmation` for an example.
|
||||
|
||||
## Running tests
|
||||
|
||||
@@ -61,28 +60,28 @@ The name of a test is based on its path, so the name of the test at `pkg/integra
|
||||
|
||||
You can pass the KEY_PRESS_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses, which helps for watching a test at a realistic speed to understand what it's doing. Or you can pass the '--slow' flag which sets a pre-set 'slow' key delay. In the tui you can press 't' to run the test in slow mode.
|
||||
|
||||
The resultant repo will be stored in `test/integration_new`, so if you're not sure what went wrong you can go there and inspect the repo.
|
||||
|
||||
### Running tests in VSCode
|
||||
|
||||
If you've opened an integration test file in your editor you can run that file by bringing up the command panel with `cmd+shift+p` and typing 'run task', then selecting the test task you want to run
|
||||
If you've opened an integration test file in your editor you can run that file by bringing up the command panel with `cmd+shift+p` and typing 'run task', then selecting the test task you want to run
|
||||
|
||||

|
||||

|
||||
The test will run in a VSCode terminal:
|
||||

|
||||
|
||||
### Snapshots
|
||||
|
||||
At the moment (this is subject to change) each test has a snapshot repo created after running for the first time. These snapshots live in `test/integration_new`, in folders named 'expected' (alongside the 'actual' folders which contain the resulting repo from the last test run). Whenever you run a test, the resultant repo will be compared against the snapshot repo and if they're different, you'll be asked whether you want to update the snapshot. If you want to update a snapshot without being prompted you can pass MODE=update to the test runner.
|
||||
|
||||
### Sandbox mode
|
||||
|
||||
Say you want to do a manual test of how lazygit handles merge-conflicts, but you can't be bothered actually finding a way to create merge conflicts in a repo. To make your life easier, you can simply run a merge-conflicts test in sandbox mode, meaning the setup step is run for you, and then instead of the test driving the lazygit session, you're allowed to drive it yourself.
|
||||
|
||||
To run a test in sandbox mode you can press 's' on a test in the test TUI or in the test runner pass MODE=sandbox or the --sandbox argument.
|
||||
To run a test in sandbox mode you can press 's' on a test in the test TUI or in the test runner pass the --sandbox argument.
|
||||
|
||||
## Migration process
|
||||
|
||||
At the time of writing, most tests are created under an old approach, where you would record yourself in a lazygit session and then the test would replay the keybindings with the same timestamps. This old approach is great for writing tests quickly, but is much harder to maintain. It has to rely entirely on snapshots to determining if a test passes or fails, and can't do assertions along the way. It's also harder to grok what's the intention behind certain actions that take place within the test (e.g. was the recorder intentionally switching to another panel or was that just a misclick?).
|
||||
You can watch how to migrate tests in this youtube [video](https://youtu.be/cJtOJu6-HcA).
|
||||
|
||||
At the time of writing, most tests are created under an old approach, where you would record yourself in a lazygit session and then the test would replay the keybindings with the same timestamps. This old approach is great for writing tests quickly, but is much harder to maintain. It has to rely on snapshots to determining if a test passes or fails, and can't do assertions along the way. It's also harder to grok what's the intention behind certain actions that take place within the test (e.g. was the recorder intentionally switching to another panel or was that just a misclick?).
|
||||
|
||||
At the moment, all the deprecated test code lives in pkg/integration/deprecated. Hopefully in the very near future we migrate everything across so that we don't need to maintain two systems.
|
||||
|
||||
@@ -92,6 +91,6 @@ We should never write any new tests under the old method, and if a given test br
|
||||
go run pkg/integration/deprecated/cmd/tui/main.go
|
||||
```
|
||||
|
||||
The tests in the old format live in test/integration. In the old format, test definitions are co-located with the snapshots. The setup step is done in a `setup.sh` shell script and the `recording.json` file contains the recorded keypresses to be replayed during the test.
|
||||
The tests in the old format live in test/integration. In the old format, test definitions are co-located with snapshots. The setup step is done in a `setup.sh` shell script and the `recording.json` file contains the recorded keypresses to be replayed during the test.
|
||||
|
||||
If you have rewritten an integration test under the new pattern, be sure to delete the old integration test directory.
|
||||
|
||||
@@ -29,19 +29,12 @@ func RunCLI(testNames []string, slow bool, sandbox bool) {
|
||||
keyPressDelay = SLOW_KEY_PRESS_DELAY
|
||||
}
|
||||
|
||||
var mode components.Mode
|
||||
if sandbox {
|
||||
mode = components.SANDBOX
|
||||
} else {
|
||||
mode = getModeFromEnv()
|
||||
}
|
||||
|
||||
err := components.RunTests(
|
||||
getTestsToRun(testNames),
|
||||
log.Printf,
|
||||
runCmdInTerminal,
|
||||
runAndPrintFatalError,
|
||||
mode,
|
||||
sandbox,
|
||||
keyPressDelay,
|
||||
1,
|
||||
)
|
||||
@@ -95,22 +88,6 @@ func runCmdInTerminal(cmd *exec.Cmd) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func getModeFromEnv() components.Mode {
|
||||
switch os.Getenv("MODE") {
|
||||
case "", "ask":
|
||||
return components.ASK_TO_UPDATE_SNAPSHOT
|
||||
case "check":
|
||||
return components.CHECK_SNAPSHOT
|
||||
case "update":
|
||||
return components.UPDATE_SNAPSHOT
|
||||
case "sandbox":
|
||||
return components.SANDBOX
|
||||
default:
|
||||
log.Fatalf("unknown test mode: %s, must be one of [ask, check, update, sandbox]", os.Getenv("MODE"))
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func tryConvert(numStr string, defaultVal int) int {
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIntegration(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
},
|
||||
components.CHECK_SNAPSHOT,
|
||||
false,
|
||||
0,
|
||||
// allowing two attempts at the test. If a test fails intermittently,
|
||||
// there may be a concurrency issue that we need to resolve.
|
||||
|
||||
@@ -39,11 +39,6 @@ func getIntegrationTest() integrationTypes.IntegrationTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.Getenv(components.SANDBOX_ENV_VAR) == "true" {
|
||||
// when in sandbox mode we don't want the test controlling the gui
|
||||
return nil
|
||||
}
|
||||
|
||||
integrationTestName := os.Getenv(components.TEST_NAME_ENV_VAR)
|
||||
if integrationTestName == "" {
|
||||
panic(fmt.Sprintf(
|
||||
|
||||
@@ -82,7 +82,7 @@ func RunTUI() {
|
||||
return nil
|
||||
}
|
||||
|
||||
suspendAndRunTest(currentTest, components.SANDBOX, 0)
|
||||
suspendAndRunTest(currentTest, true, 0)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -95,7 +95,7 @@ func RunTUI() {
|
||||
return nil
|
||||
}
|
||||
|
||||
suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 0)
|
||||
suspendAndRunTest(currentTest, false, 0)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -108,7 +108,7 @@ func RunTUI() {
|
||||
return nil
|
||||
}
|
||||
|
||||
suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, SLOW_KEY_PRESS_DELAY)
|
||||
suspendAndRunTest(currentTest, false, SLOW_KEY_PRESS_DELAY)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -268,12 +268,12 @@ func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod go
|
||||
}
|
||||
}
|
||||
|
||||
func suspendAndRunTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
|
||||
func suspendAndRunTest(test *components.IntegrationTest, sandbox bool, keyPressDelay int) {
|
||||
if err := gocui.Screen.Suspend(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
runTuiTest(test, mode, keyPressDelay)
|
||||
runTuiTest(test, sandbox, keyPressDelay)
|
||||
|
||||
fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return"))
|
||||
fmt.Scanln() // wait for enter press
|
||||
@@ -367,13 +367,13 @@ func quit(g *gocui.Gui, v *gocui.View) error {
|
||||
return gocui.ErrQuit
|
||||
}
|
||||
|
||||
func runTuiTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
|
||||
func runTuiTest(test *components.IntegrationTest, sandbox bool, keyPressDelay int) {
|
||||
err := components.RunTests(
|
||||
[]*components.IntegrationTest{test},
|
||||
log.Printf,
|
||||
runCmdInTerminal,
|
||||
runAndPrintError,
|
||||
mode,
|
||||
sandbox,
|
||||
keyPressDelay,
|
||||
1,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package components
|
||||
|
||||
// for running common actions
|
||||
type Actions struct {
|
||||
t *TestDriver
|
||||
}
|
||||
|
||||
func (self *Actions) ContinueMerge() {
|
||||
self.t.Views().current().Press(self.t.keys.Universal.CreateRebaseOptionsMenu)
|
||||
|
||||
self.t.ExpectPopup().Menu().
|
||||
Title(Equals("Rebase Options")).
|
||||
Select(Contains("continue")).
|
||||
Confirm()
|
||||
}
|
||||
|
||||
func (self *Actions) ContinueRebase() {
|
||||
self.ContinueMerge()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package components
|
||||
|
||||
type AlertDriver struct {
|
||||
t *TestDriver
|
||||
hasCheckedTitle bool
|
||||
hasCheckedContent bool
|
||||
}
|
||||
|
||||
func (self *AlertDriver) getViewDriver() *ViewDriver {
|
||||
return self.t.Views().Confirmation()
|
||||
}
|
||||
|
||||
// asserts that the alert view has the expected title
|
||||
func (self *AlertDriver) Title(expected *matcher) *AlertDriver {
|
||||
self.getViewDriver().Title(expected)
|
||||
|
||||
self.hasCheckedTitle = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that the alert view has the expected content
|
||||
func (self *AlertDriver) Content(expected *matcher) *AlertDriver {
|
||||
self.getViewDriver().Content(expected)
|
||||
|
||||
self.hasCheckedContent = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *AlertDriver) Confirm() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEnter()
|
||||
}
|
||||
|
||||
func (self *AlertDriver) Cancel() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEscape()
|
||||
}
|
||||
|
||||
func (self *AlertDriver) checkNecessaryChecksCompleted() {
|
||||
if !self.hasCheckedContent || !self.hasCheckedTitle {
|
||||
self.t.Fail("You must both check the content and title of a confirmation popup by calling Title()/Content() before calling Confirm()/Cancel().")
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
)
|
||||
|
||||
// through this struct we assert on the state of the lazygit gui
|
||||
|
||||
type Assert struct {
|
||||
gui integrationTypes.GuiDriver
|
||||
}
|
||||
|
||||
func NewAssert(gui integrationTypes.GuiDriver) *Assert {
|
||||
return &Assert{gui: gui}
|
||||
}
|
||||
|
||||
// for making assertions on string values
|
||||
type matcher struct {
|
||||
testFn func(string) (bool, string)
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (self *matcher) test(value string) (bool, string) {
|
||||
ok, message := self.testFn(value)
|
||||
if ok {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
if self.prefix != "" {
|
||||
return false, self.prefix + " " + message
|
||||
}
|
||||
|
||||
return false, message
|
||||
}
|
||||
|
||||
func (self *matcher) context(prefix string) *matcher {
|
||||
self.prefix = prefix
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func Contains(target string) *matcher {
|
||||
return &matcher{testFn: func(value string) (bool, string) {
|
||||
return strings.Contains(value, target), fmt.Sprintf("Expected '%s' to be found in '%s'", target, value)
|
||||
}}
|
||||
}
|
||||
|
||||
func NotContains(target string) *matcher {
|
||||
return &matcher{testFn: func(value string) (bool, string) {
|
||||
return !strings.Contains(value, target), fmt.Sprintf("Expected '%s' to NOT be found in '%s'", target, value)
|
||||
}}
|
||||
}
|
||||
|
||||
func Equals(target string) *matcher {
|
||||
return &matcher{testFn: func(value string) (bool, string) {
|
||||
return target == value, fmt.Sprintf("Expected '%s' to equal '%s'", value, target)
|
||||
}}
|
||||
}
|
||||
|
||||
func (self *Assert) WorkingTreeFileCount(expectedCount int) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actualCount := len(self.gui.Model().Files)
|
||||
|
||||
return actualCount == expectedCount, fmt.Sprintf(
|
||||
"Expected %d changed working tree files, but got %d",
|
||||
expectedCount, actualCount,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) CommitCount(expectedCount int) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actualCount := len(self.gui.Model().Commits)
|
||||
|
||||
return actualCount == expectedCount, fmt.Sprintf(
|
||||
"Expected %d commits present, but got %d",
|
||||
expectedCount, actualCount,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) StashCount(expectedCount int) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actualCount := len(self.gui.Model().StashEntries)
|
||||
|
||||
return actualCount == expectedCount, fmt.Sprintf(
|
||||
"Expected %d stash entries, but got %d",
|
||||
expectedCount, actualCount,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) AtLeastOneCommit() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actualCount := len(self.gui.Model().Commits)
|
||||
|
||||
return actualCount > 0, "Expected at least one commit present"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) MatchHeadCommitMessage(matcher *matcher) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
return len(self.gui.Model().Commits) > 0, "Expected at least one commit to be present"
|
||||
})
|
||||
|
||||
self.matchString(matcher, "Unexpected commit message.",
|
||||
func() string {
|
||||
return self.gui.Model().Commits[0].Name
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) CurrentViewName(expectedViewName string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actual := self.gui.CurrentContext().GetView().Name()
|
||||
return actual == expectedViewName, fmt.Sprintf("Expected current view name to be '%s', but got '%s'", expectedViewName, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) CurrentWindowName(expectedWindowName string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actual := self.gui.CurrentContext().GetView().Name()
|
||||
return actual == expectedWindowName, fmt.Sprintf("Expected current window name to be '%s', but got '%s'", expectedWindowName, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) CurrentBranchName(expectedViewName string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
actual := self.gui.CheckedOutRef().Name
|
||||
return actual == expectedViewName, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expectedViewName, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) InListContext() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
currentContext := self.gui.CurrentContext()
|
||||
_, ok := currentContext.(types.IListContext)
|
||||
return ok, fmt.Sprintf("Expected current context to be a list context, but got %s", currentContext.GetKey())
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) MatchSelectedLine(matcher *matcher) {
|
||||
self.matchString(matcher, "Unexpected selected line.",
|
||||
func() string {
|
||||
return self.gui.CurrentContext().GetView().SelectedLine()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) InPrompt() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && currentView.Editable, "Expected prompt popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) InConfirm() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && !currentView.Editable, "Expected confirmation popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) InAlert() {
|
||||
// basically the same thing as a confirmation popup with the current implementation
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && !currentView.Editable, "Expected alert popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) InMenu() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
return self.gui.CurrentContext().GetView().Name() == "menu", "Expected popup menu to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) MatchCurrentViewTitle(matcher *matcher) {
|
||||
self.matchString(matcher, "Unexpected current view title.",
|
||||
func() string {
|
||||
return self.gui.CurrentContext().GetView().Title
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) MatchViewContent(viewName string, matcher *matcher) {
|
||||
self.matchString(matcher, fmt.Sprintf("Unexpected content in view '%s'.", viewName),
|
||||
func() string {
|
||||
return self.gui.View(viewName).Buffer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) MatchCurrentViewContent(matcher *matcher) {
|
||||
self.matchString(matcher, "Unexpected content in current view.",
|
||||
func() string {
|
||||
return self.gui.CurrentContext().GetView().Buffer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) MatchMainViewContent(matcher *matcher) {
|
||||
self.matchString(matcher, "Unexpected main view content.",
|
||||
func() string {
|
||||
return self.gui.MainView().Buffer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) MatchSecondaryViewContent(matcher *matcher) {
|
||||
self.matchString(matcher, "Unexpected secondary view title.",
|
||||
func() string {
|
||||
return self.gui.SecondaryView().Buffer()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *Assert) matchString(matcher *matcher, context string, getValue func() string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
value := getValue()
|
||||
return matcher.context(context).test(value)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Assert) assertWithRetries(test func() (bool, string)) {
|
||||
waitTimes := []int{0, 1, 5, 10, 200, 500, 1000, 2000, 4000}
|
||||
|
||||
var message string
|
||||
for _, waitTime := range waitTimes {
|
||||
time.Sleep(time.Duration(waitTime) * time.Millisecond)
|
||||
|
||||
var ok bool
|
||||
ok, message = test()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self.Fail(message)
|
||||
}
|
||||
|
||||
// for when you just want to fail the test yourself
|
||||
func (self *Assert) Fail(message string) {
|
||||
self.gui.Fail(message)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
)
|
||||
|
||||
type assertionHelper struct {
|
||||
gui integrationTypes.GuiDriver
|
||||
}
|
||||
|
||||
// milliseconds we'll wait when an assertion fails.
|
||||
var retryWaitTimes = []int{0, 1, 1, 1, 1, 1, 5, 10, 20, 40, 100, 200, 500, 1000, 2000, 4000}
|
||||
|
||||
func (self *assertionHelper) matchString(matcher *matcher, context string, getValue func() string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
value := getValue()
|
||||
return matcher.context(context).test(value)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *assertionHelper) assertWithRetries(test func() (bool, string)) {
|
||||
var message string
|
||||
for _, waitTime := range retryWaitTimes {
|
||||
time.Sleep(time.Duration(waitTime) * time.Millisecond)
|
||||
|
||||
var ok bool
|
||||
ok, message = test()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self.fail(message)
|
||||
}
|
||||
|
||||
func (self *assertionHelper) fail(message string) {
|
||||
self.gui.Fail(message)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package components
|
||||
|
||||
type CommitMessagePanelDriver struct {
|
||||
t *TestDriver
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) getViewDriver() *ViewDriver {
|
||||
return self.t.Views().CommitMessage()
|
||||
}
|
||||
|
||||
// asserts on the text initially present in the prompt
|
||||
func (self *CommitMessagePanelDriver) InitialText(expected *matcher) *CommitMessagePanelDriver {
|
||||
self.getViewDriver().Content(expected)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) Type(value string) *CommitMessagePanelDriver {
|
||||
self.t.typeContent(value)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) AddNewline() *CommitMessagePanelDriver {
|
||||
self.t.press(self.t.keys.Universal.AppendNewline)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) Clear() *CommitMessagePanelDriver {
|
||||
panic("Clear method not yet implemented!")
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) Confirm() {
|
||||
self.getViewDriver().PressEnter()
|
||||
}
|
||||
|
||||
func (self *CommitMessagePanelDriver) Cancel() {
|
||||
self.getViewDriver().PressEscape()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package components
|
||||
|
||||
type ConfirmationDriver struct {
|
||||
t *TestDriver
|
||||
hasCheckedTitle bool
|
||||
hasCheckedContent bool
|
||||
}
|
||||
|
||||
func (self *ConfirmationDriver) getViewDriver() *ViewDriver {
|
||||
return self.t.Views().Confirmation()
|
||||
}
|
||||
|
||||
// asserts that the confirmation view has the expected title
|
||||
func (self *ConfirmationDriver) Title(expected *matcher) *ConfirmationDriver {
|
||||
self.getViewDriver().Title(expected)
|
||||
|
||||
self.hasCheckedTitle = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that the confirmation view has the expected content
|
||||
func (self *ConfirmationDriver) Content(expected *matcher) *ConfirmationDriver {
|
||||
self.getViewDriver().Content(expected)
|
||||
|
||||
self.hasCheckedContent = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ConfirmationDriver) Confirm() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEnter()
|
||||
}
|
||||
|
||||
func (self *ConfirmationDriver) Cancel() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEscape()
|
||||
}
|
||||
|
||||
func (self *ConfirmationDriver) checkNecessaryChecksCompleted() {
|
||||
if !self.hasCheckedContent || !self.hasCheckedTitle {
|
||||
self.t.Fail("You must both check the content and title of a confirmation popup by calling Title()/Content() before calling Confirm()/Cancel().")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type FileSystem struct {
|
||||
*assertionHelper
|
||||
}
|
||||
|
||||
// This does _not_ check the files panel, it actually checks the filesystem
|
||||
func (self *FileSystem) PathPresent(path string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil, fmt.Sprintf("Expected path '%s' to exist, but it does not", path)
|
||||
})
|
||||
}
|
||||
|
||||
// This does _not_ check the files panel, it actually checks the filesystem
|
||||
func (self *FileSystem) PathNotPresent(path string) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
_, err := os.Stat(path)
|
||||
return os.IsNotExist(err), fmt.Sprintf("Expected path '%s' to not exist, but it does", path)
|
||||
})
|
||||
}
|
||||
|
||||
// Asserts that the file at the given path has the given content
|
||||
func (self *FileSystem) FileContent(path string, matcher *matcher) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, fmt.Sprintf("Expected path '%s' to not exist, but it does", path)
|
||||
}
|
||||
|
||||
output, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Expected error when reading file content at path '%s': %s", path, err.Error())
|
||||
}
|
||||
|
||||
strOutput := string(output)
|
||||
|
||||
if ok, errMsg := matcher.context("").test(strOutput); !ok {
|
||||
return false, fmt.Sprintf("Unexpected content in file %s: %s", path, errMsg)
|
||||
}
|
||||
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Git struct {
|
||||
*assertionHelper
|
||||
shell *Shell
|
||||
}
|
||||
|
||||
func (self *Git) CurrentBranchName(expectedName string) *Git {
|
||||
return self.assert("git rev-parse --abbrev-ref HEAD", expectedName)
|
||||
}
|
||||
|
||||
func (self *Git) assert(cmdStr string, expected string) *Git {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
output, err := self.shell.runCommandWithOutput(cmdStr)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Unexpected error running command: `%s`. Error: %s", cmdStr, err.Error())
|
||||
}
|
||||
actual := strings.TrimSpace(output)
|
||||
return actual == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
gui integrationTypes.GuiDriver
|
||||
keys config.KeybindingConfig
|
||||
assert *Assert
|
||||
pushKeyDelay int
|
||||
}
|
||||
|
||||
func NewInput(gui integrationTypes.GuiDriver, keys config.KeybindingConfig, assert *Assert, pushKeyDelay int) *Input {
|
||||
return &Input{
|
||||
gui: gui,
|
||||
keys: keys,
|
||||
assert: assert,
|
||||
pushKeyDelay: pushKeyDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// key is something like 'w' or '<space>'. It's best not to pass a direct value,
|
||||
// but instead to go through the default user config to get a more meaningful key name
|
||||
func (self *Input) PressKeys(keyStrs ...string) {
|
||||
for _, keyStr := range keyStrs {
|
||||
self.pressKey(keyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Input) pressKey(keyStr string) {
|
||||
self.Wait(self.pushKeyDelay)
|
||||
|
||||
self.gui.PressKey(keyStr)
|
||||
}
|
||||
|
||||
func (self *Input) SwitchToStatusWindow() {
|
||||
self.pressKey(self.keys.Universal.JumpToBlock[0])
|
||||
self.assert.CurrentWindowName("status")
|
||||
}
|
||||
|
||||
func (self *Input) SwitchToFilesWindow() {
|
||||
self.pressKey(self.keys.Universal.JumpToBlock[1])
|
||||
self.assert.CurrentWindowName("files")
|
||||
}
|
||||
|
||||
func (self *Input) SwitchToBranchesWindow() {
|
||||
self.pressKey(self.keys.Universal.JumpToBlock[2])
|
||||
self.assert.CurrentWindowName("localBranches")
|
||||
}
|
||||
|
||||
func (self *Input) SwitchToCommitsWindow() {
|
||||
self.pressKey(self.keys.Universal.JumpToBlock[3])
|
||||
self.assert.CurrentWindowName("commits")
|
||||
}
|
||||
|
||||
func (self *Input) SwitchToStashWindow() {
|
||||
self.pressKey(self.keys.Universal.JumpToBlock[4])
|
||||
self.assert.CurrentWindowName("stash")
|
||||
}
|
||||
|
||||
func (self *Input) Type(content string) {
|
||||
for _, char := range content {
|
||||
self.pressKey(string(char))
|
||||
}
|
||||
}
|
||||
|
||||
// i.e. pressing enter
|
||||
func (self *Input) Confirm() {
|
||||
self.pressKey(self.keys.Universal.Confirm)
|
||||
}
|
||||
|
||||
func (self *Input) ProceedWhenAsked(matcher *matcher) {
|
||||
self.assert.InConfirm()
|
||||
self.assert.MatchCurrentViewContent(matcher)
|
||||
self.Confirm()
|
||||
}
|
||||
|
||||
// i.e. same as Confirm
|
||||
func (self *Input) Enter() {
|
||||
self.pressKey(self.keys.Universal.Confirm)
|
||||
}
|
||||
|
||||
// i.e. pressing escape
|
||||
func (self *Input) Cancel() {
|
||||
self.pressKey(self.keys.Universal.Return)
|
||||
}
|
||||
|
||||
// i.e. pressing space
|
||||
func (self *Input) PrimaryAction() {
|
||||
self.pressKey(self.keys.Universal.Select)
|
||||
}
|
||||
|
||||
// i.e. pressing down arrow
|
||||
func (self *Input) NextItem() {
|
||||
self.pressKey(self.keys.Universal.NextItem)
|
||||
}
|
||||
|
||||
// i.e. pressing up arrow
|
||||
func (self *Input) PreviousItem() {
|
||||
self.pressKey(self.keys.Universal.PrevItem)
|
||||
}
|
||||
|
||||
func (self *Input) ContinueMerge() {
|
||||
self.PressKeys(self.keys.Universal.CreateRebaseOptionsMenu)
|
||||
self.assert.MatchSelectedLine(Contains("continue"))
|
||||
self.Confirm()
|
||||
}
|
||||
|
||||
func (self *Input) ContinueRebase() {
|
||||
self.ContinueMerge()
|
||||
}
|
||||
|
||||
// for when you want to allow lazygit to process something before continuing
|
||||
func (self *Input) Wait(milliseconds int) {
|
||||
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (self *Input) LogUI(message string) {
|
||||
self.gui.LogUI(message)
|
||||
}
|
||||
|
||||
func (self *Input) Log(message string) {
|
||||
self.gui.LogUI(message)
|
||||
}
|
||||
|
||||
// this will look for a list item in the current panel and if it finds it, it will
|
||||
// enter the keypresses required to navigate to it.
|
||||
// The test will fail if:
|
||||
// - the user is not in a list item
|
||||
// - no list item is found containing the given text
|
||||
// - multiple list items are found containing the given text in the initial page of items
|
||||
//
|
||||
// NOTE: this currently assumes that ViewBufferLines returns all the lines that can be accessed.
|
||||
// If this changes in future, we'll need to update this code to first attempt to find the item
|
||||
// in the current page and failing that, jump to the top of the view and iterate through all of it,
|
||||
// looking for the item.
|
||||
func (self *Input) NavigateToListItemContainingText(text string) {
|
||||
self.assert.InListContext()
|
||||
|
||||
currentContext := self.gui.CurrentContext().(types.IListContext)
|
||||
|
||||
view := currentContext.GetView()
|
||||
|
||||
var matchIndex int
|
||||
|
||||
self.assert.assertWithRetries(func() (bool, string) {
|
||||
matchCount := 0
|
||||
matchIndex = -1
|
||||
// first we look for a duplicate on the current screen. We won't bother looking beyond that though.
|
||||
for i, line := range view.ViewBufferLines() {
|
||||
if strings.Contains(line, text) {
|
||||
matchCount++
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return false, fmt.Sprintf("Found %d matches for %s, expected only a single match", matchCount, text)
|
||||
} else if matchCount == 0 {
|
||||
return false, fmt.Sprintf("Could not find item containing text: %s", text)
|
||||
} else {
|
||||
return true, ""
|
||||
}
|
||||
})
|
||||
|
||||
selectedLineIdx := view.SelectedLineIdx()
|
||||
if selectedLineIdx == matchIndex {
|
||||
self.assert.MatchSelectedLine(Contains(text))
|
||||
return
|
||||
}
|
||||
if selectedLineIdx < matchIndex {
|
||||
for i := selectedLineIdx; i < matchIndex; i++ {
|
||||
self.NextItem()
|
||||
}
|
||||
self.assert.MatchSelectedLine(Contains(text))
|
||||
return
|
||||
} else {
|
||||
for i := selectedLineIdx; i > matchIndex; i-- {
|
||||
self.PreviousItem()
|
||||
}
|
||||
self.assert.MatchSelectedLine(Contains(text))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// for making assertions on string values
|
||||
type matcher struct {
|
||||
rules []matcherRule
|
||||
|
||||
// this is printed when there's an error so that it's clear what the context of the assertion is
|
||||
prefix string
|
||||
}
|
||||
|
||||
type matcherRule struct {
|
||||
// e.g. "contains 'foo'"
|
||||
name string
|
||||
// returns a bool that says whether the test passed and if it returns false, it
|
||||
// also returns a string of the error message
|
||||
testFn func(string) (bool, string)
|
||||
}
|
||||
|
||||
func NewMatcher(name string, testFn func(string) (bool, string)) *matcher {
|
||||
rules := []matcherRule{{name: name, testFn: testFn}}
|
||||
return &matcher{rules: rules}
|
||||
}
|
||||
|
||||
func (self *matcher) name() string {
|
||||
if len(self.rules) == 0 {
|
||||
return "anything"
|
||||
}
|
||||
|
||||
return strings.Join(
|
||||
lo.Map(self.rules, func(rule matcherRule, _ int) string { return rule.name }),
|
||||
", ",
|
||||
)
|
||||
}
|
||||
|
||||
func (self *matcher) test(value string) (bool, string) {
|
||||
for _, rule := range self.rules {
|
||||
ok, message := rule.testFn(value)
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if self.prefix != "" {
|
||||
return false, self.prefix + " " + message
|
||||
}
|
||||
|
||||
return false, message
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (self *matcher) Contains(target string) *matcher {
|
||||
return self.appendRule(matcherRule{
|
||||
name: fmt.Sprintf("contains '%s'", target),
|
||||
testFn: func(value string) (bool, string) {
|
||||
return strings.Contains(value, target), fmt.Sprintf("Expected '%s' to be found in '%s'", target, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *matcher) DoesNotContain(target string) *matcher {
|
||||
return self.appendRule(matcherRule{
|
||||
name: fmt.Sprintf("does not contain '%s'", target),
|
||||
testFn: func(value string) (bool, string) {
|
||||
return !strings.Contains(value, target), fmt.Sprintf("Expected '%s' to NOT be found in '%s'", target, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *matcher) MatchesRegexp(target string) *matcher {
|
||||
return self.appendRule(matcherRule{
|
||||
name: fmt.Sprintf("matches regular expression '%s'", target),
|
||||
testFn: func(value string) (bool, string) {
|
||||
matched, err := regexp.MatchString(target, value)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Unexpected error parsing regular expression '%s': %s", target, err.Error())
|
||||
}
|
||||
return matched, fmt.Sprintf("Expected '%s' to match regular expression '%s'", value, target)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *matcher) Equals(target string) *matcher {
|
||||
return self.appendRule(matcherRule{
|
||||
name: fmt.Sprintf("equals '%s'", target),
|
||||
testFn: func(value string) (bool, string) {
|
||||
return target == value, fmt.Sprintf("Expected '%s' to equal '%s'", value, target)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const IS_SELECTED_RULE_NAME = "is selected"
|
||||
|
||||
// special rule that is only to be used in the TopLines and Lines methods, as a way of
|
||||
// asserting that a given line is selected.
|
||||
func (self *matcher) IsSelected() *matcher {
|
||||
return self.appendRule(matcherRule{
|
||||
name: IS_SELECTED_RULE_NAME,
|
||||
testFn: func(value string) (bool, string) {
|
||||
panic("Special IsSelected matcher is not supposed to have its testFn method called. This rule should only be used within the .Lines() and .TopLines() method on a ViewAsserter.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *matcher) appendRule(rule matcherRule) *matcher {
|
||||
self.rules = append(self.rules, rule)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// adds context so that if the matcher test(s) fails, we understand what we were trying to test.
|
||||
// E.g. prefix: "Unexpected content in view 'files'."
|
||||
func (self *matcher) context(prefix string) *matcher {
|
||||
self.prefix = prefix
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// if the matcher has an `IsSelected` rule, it returns true, along with the matcher after that rule has been removed
|
||||
func (self *matcher) checkIsSelected() (bool, *matcher) {
|
||||
check := lo.ContainsBy(self.rules, func(rule matcherRule) bool { return rule.name == IS_SELECTED_RULE_NAME })
|
||||
|
||||
self.rules = lo.Filter(self.rules, func(rule matcherRule, _ int) bool { return rule.name != IS_SELECTED_RULE_NAME })
|
||||
|
||||
return check, self
|
||||
}
|
||||
|
||||
// this matcher has no rules meaning it always passes the test. Use this
|
||||
// when you don't care what value you're dealing with.
|
||||
func Anything() *matcher {
|
||||
return &matcher{}
|
||||
}
|
||||
|
||||
func Contains(target string) *matcher {
|
||||
return Anything().Contains(target)
|
||||
}
|
||||
|
||||
func DoesNotContain(target string) *matcher {
|
||||
return Anything().DoesNotContain(target)
|
||||
}
|
||||
|
||||
func MatchesRegexp(target string) *matcher {
|
||||
return Anything().MatchesRegexp(target)
|
||||
}
|
||||
|
||||
func Equals(target string) *matcher {
|
||||
return Anything().Equals(target)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package components
|
||||
|
||||
type MenuDriver struct {
|
||||
t *TestDriver
|
||||
hasCheckedTitle bool
|
||||
}
|
||||
|
||||
func (self *MenuDriver) getViewDriver() *ViewDriver {
|
||||
return self.t.Views().Menu()
|
||||
}
|
||||
|
||||
// asserts that the popup has the expected title
|
||||
func (self *MenuDriver) Title(expected *matcher) *MenuDriver {
|
||||
self.getViewDriver().Title(expected)
|
||||
|
||||
self.hasCheckedTitle = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *MenuDriver) Confirm() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEnter()
|
||||
}
|
||||
|
||||
func (self *MenuDriver) Cancel() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEscape()
|
||||
}
|
||||
|
||||
func (self *MenuDriver) Select(option *matcher) *MenuDriver {
|
||||
self.getViewDriver().NavigateToListItem(option)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *MenuDriver) checkNecessaryChecksCompleted() {
|
||||
if !self.hasCheckedTitle {
|
||||
self.t.Fail("You must check the title of a menu popup by calling Title() before calling Confirm()/Cancel().")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package components
|
||||
|
||||
type Popup struct {
|
||||
t *TestDriver
|
||||
}
|
||||
|
||||
func (self *Popup) Confirmation() *ConfirmationDriver {
|
||||
self.inConfirm()
|
||||
|
||||
return &ConfirmationDriver{t: self.t}
|
||||
}
|
||||
|
||||
func (self *Popup) inConfirm() {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.t.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && !currentView.Editable, "Expected confirmation popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Popup) Prompt() *PromptDriver {
|
||||
self.inPrompt()
|
||||
|
||||
return &PromptDriver{t: self.t}
|
||||
}
|
||||
|
||||
func (self *Popup) inPrompt() {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.t.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && currentView.Editable, "Expected prompt popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Popup) Alert() *AlertDriver {
|
||||
self.inAlert()
|
||||
|
||||
return &AlertDriver{t: self.t}
|
||||
}
|
||||
|
||||
func (self *Popup) inAlert() {
|
||||
// basically the same thing as a confirmation popup with the current implementation
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.t.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "confirmation" && !currentView.Editable, "Expected alert popup to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Popup) Menu() *MenuDriver {
|
||||
self.inMenu()
|
||||
|
||||
return &MenuDriver{t: self.t}
|
||||
}
|
||||
|
||||
func (self *Popup) inMenu() {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
return self.t.gui.CurrentContext().GetView().Name() == "menu", "Expected popup menu to be focused"
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Popup) CommitMessagePanel() *CommitMessagePanelDriver {
|
||||
self.inCommitMessagePanel()
|
||||
|
||||
return &CommitMessagePanelDriver{t: self.t}
|
||||
}
|
||||
|
||||
func (self *Popup) inCommitMessagePanel() {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
currentView := self.t.gui.CurrentContext().GetView()
|
||||
return currentView.Name() == "commitMessage", "Expected commit message panel to be focused"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package components
|
||||
|
||||
type PromptDriver struct {
|
||||
t *TestDriver
|
||||
hasCheckedTitle bool
|
||||
}
|
||||
|
||||
func (self *PromptDriver) getViewDriver() *ViewDriver {
|
||||
return self.t.Views().Confirmation()
|
||||
}
|
||||
|
||||
// asserts that the popup has the expected title
|
||||
func (self *PromptDriver) Title(expected *matcher) *PromptDriver {
|
||||
self.getViewDriver().Title(expected)
|
||||
|
||||
self.hasCheckedTitle = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts on the text initially present in the prompt
|
||||
func (self *PromptDriver) InitialText(expected *matcher) *PromptDriver {
|
||||
self.getViewDriver().Content(expected)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *PromptDriver) Type(value string) *PromptDriver {
|
||||
self.t.typeContent(value)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *PromptDriver) Clear() *PromptDriver {
|
||||
panic("Clear method not yet implemented!")
|
||||
}
|
||||
|
||||
func (self *PromptDriver) Confirm() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEnter()
|
||||
}
|
||||
|
||||
func (self *PromptDriver) Cancel() {
|
||||
self.checkNecessaryChecksCompleted()
|
||||
|
||||
self.getViewDriver().PressEscape()
|
||||
}
|
||||
|
||||
func (self *PromptDriver) checkNecessaryChecksCompleted() {
|
||||
if !self.hasCheckedTitle {
|
||||
self.t.Fail("You must check the title of a prompt popup by calling Title() before calling Confirm()/Cancel().")
|
||||
}
|
||||
}
|
||||
|
||||
func (self *PromptDriver) SuggestionLines(matchers ...*matcher) *PromptDriver {
|
||||
self.t.Views().Suggestions().Lines(matchers...)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *PromptDriver) SuggestionTopLines(matchers ...*matcher) *PromptDriver {
|
||||
self.t.Views().Suggestions().TopLines(matchers...)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *PromptDriver) ConfirmFirstSuggestion() {
|
||||
self.t.press(self.t.keys.Universal.TogglePanel)
|
||||
self.t.Views().Suggestions().
|
||||
IsFocused().
|
||||
SelectedLineIdx(0).
|
||||
PressEnter()
|
||||
}
|
||||
|
||||
func (self *PromptDriver) ConfirmSuggestion(matcher *matcher) {
|
||||
self.t.press(self.t.keys.Universal.TogglePanel)
|
||||
self.t.Views().Suggestions().
|
||||
IsFocused().
|
||||
NavigateToListItem(matcher).
|
||||
PressEnter()
|
||||
}
|
||||
@@ -18,29 +18,12 @@ const (
|
||||
SANDBOX_ENV_VAR = "SANDBOX"
|
||||
)
|
||||
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// Default: if a snapshot test fails, the we'll be asked whether we want to update it
|
||||
ASK_TO_UPDATE_SNAPSHOT Mode = iota
|
||||
// fails the test if the snapshots don't match
|
||||
CHECK_SNAPSHOT
|
||||
// runs the test and updates the snapshot
|
||||
UPDATE_SNAPSHOT
|
||||
// This just makes use of the setup step of the test to get you into
|
||||
// a lazygit session. Then you'll be able to do whatever you want. Useful
|
||||
// when you want to test certain things without needing to manually set
|
||||
// up the situation yourself.
|
||||
// fails the test if the snapshots don't match
|
||||
SANDBOX
|
||||
)
|
||||
|
||||
func RunTests(
|
||||
tests []*IntegrationTest,
|
||||
logf func(format string, formatArgs ...interface{}),
|
||||
runCmd func(cmd *exec.Cmd) error,
|
||||
testWrapper func(test *IntegrationTest, f func() error),
|
||||
mode Mode,
|
||||
sandbox bool,
|
||||
keyPressDelay int,
|
||||
maxAttempts int,
|
||||
) error {
|
||||
@@ -65,7 +48,7 @@ func RunTests(
|
||||
)
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
err := runTest(test, paths, projectRootDir, logf, runCmd, mode, keyPressDelay)
|
||||
err := runTest(test, paths, projectRootDir, logf, runCmd, sandbox, keyPressDelay)
|
||||
if err != nil {
|
||||
if i == maxAttempts-1 {
|
||||
return err
|
||||
@@ -89,7 +72,7 @@ func runTest(
|
||||
projectRootDir string,
|
||||
logf func(format string, formatArgs ...interface{}),
|
||||
runCmd func(cmd *exec.Cmd) error,
|
||||
mode Mode,
|
||||
sandbox bool,
|
||||
keyPressDelay int,
|
||||
) error {
|
||||
if test.Skip() {
|
||||
@@ -103,7 +86,7 @@ func runTest(
|
||||
return err
|
||||
}
|
||||
|
||||
cmd, err := getLazygitCommand(test, paths, projectRootDir, mode, keyPressDelay)
|
||||
cmd, err := getLazygitCommand(test, paths, projectRootDir, sandbox, keyPressDelay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -113,7 +96,7 @@ func runTest(
|
||||
return err
|
||||
}
|
||||
|
||||
return HandleSnapshots(paths, logf, test, mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareTestDir(
|
||||
@@ -132,6 +115,10 @@ func prepareTestDir(
|
||||
}
|
||||
|
||||
func buildLazygit() error {
|
||||
// // TODO: remove this line!
|
||||
// // skipping this because I'm not making changes to the app code atm.
|
||||
// return nil
|
||||
|
||||
osCommand := oscommands.NewDummyOSCommand()
|
||||
return osCommand.Cmd.New(fmt.Sprintf(
|
||||
"go build -o %s pkg/integration/clients/injector/main.go", tempLazygitPath(),
|
||||
@@ -139,7 +126,7 @@ func buildLazygit() error {
|
||||
}
|
||||
|
||||
func createFixture(test *IntegrationTest, paths Paths) error {
|
||||
shell := NewShell(paths.ActualRepo())
|
||||
shell := NewShell(paths.ActualRepo(), func(errorMsg string) { panic(errorMsg) })
|
||||
shell.RunCommand("git init -b master")
|
||||
shell.RunCommand(`git config user.email "CI@example.com"`)
|
||||
shell.RunCommand(`git config user.name "CI"`)
|
||||
@@ -151,7 +138,7 @@ func createFixture(test *IntegrationTest, paths Paths) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLazygitCommand(test *IntegrationTest, paths Paths, rootDir string, mode Mode, keyPressDelay int) (*exec.Cmd, error) {
|
||||
func getLazygitCommand(test *IntegrationTest, paths Paths, rootDir string, sandbox bool, keyPressDelay int) (*exec.Cmd, error) {
|
||||
osCommand := oscommands.NewDummyOSCommand()
|
||||
|
||||
templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
|
||||
@@ -170,8 +157,8 @@ func getLazygitCommand(test *IntegrationTest, paths Paths, rootDir string, mode
|
||||
cmdObj := osCommand.Cmd.New(cmdStr)
|
||||
|
||||
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", TEST_NAME_ENV_VAR, test.Name()))
|
||||
if mode == SANDBOX {
|
||||
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", "SANDBOX", "true"))
|
||||
if sandbox {
|
||||
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", SANDBOX_ENV_VAR, "true"))
|
||||
}
|
||||
|
||||
if keyPressDelay > 0 {
|
||||
|
||||
@@ -15,93 +15,133 @@ import (
|
||||
type Shell struct {
|
||||
// working directory the shell is invoked in
|
||||
dir string
|
||||
// when running the shell outside the gui we can directly panic on failure,
|
||||
// but inside the gui we need to close the gui before panicking
|
||||
fail func(string)
|
||||
}
|
||||
|
||||
func NewShell(dir string) *Shell {
|
||||
return &Shell{dir: dir}
|
||||
func NewShell(dir string, fail func(string)) *Shell {
|
||||
return &Shell{dir: dir, fail: fail}
|
||||
}
|
||||
|
||||
func (s *Shell) RunCommand(cmdStr string) *Shell {
|
||||
func (self *Shell) RunCommand(cmdStr string) *Shell {
|
||||
args := str.ToArgv(cmdStr)
|
||||
cmd := secureexec.Command(args[0], args[1:]...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = s.dir
|
||||
cmd.Dir = self.dir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error running command: %s\n%s", cmdStr, string(output)))
|
||||
self.fail(fmt.Sprintf("error running command: %s\n%s", cmdStr, string(output)))
|
||||
}
|
||||
|
||||
return s
|
||||
return self
|
||||
}
|
||||
|
||||
func (s *Shell) CreateFile(path string, content string) *Shell {
|
||||
fullPath := filepath.Join(s.dir, path)
|
||||
func (self *Shell) runCommandWithOutput(cmdStr string) (string, error) {
|
||||
args := str.ToArgv(cmdStr)
|
||||
cmd := secureexec.Command(args[0], args[1:]...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = self.dir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func (self *Shell) RunShellCommand(cmdStr string) *Shell {
|
||||
cmd := secureexec.Command("sh", "-c", cmdStr)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = self.dir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
self.fail(fmt.Sprintf("error running shell command: %s\n%s", cmdStr, string(output)))
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *Shell) RunShellCommandExpectError(cmdStr string) *Shell {
|
||||
cmd := secureexec.Command("sh", "-c", cmdStr)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = self.dir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
self.fail(fmt.Sprintf("Expected error running shell command: %s\n%s", cmdStr, string(output)))
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *Shell) CreateFile(path string, content string) *Shell {
|
||||
fullPath := filepath.Join(self.dir, path)
|
||||
err := os.WriteFile(fullPath, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error creating file: %s\n%s", fullPath, err))
|
||||
self.fail(fmt.Sprintf("error creating file: %s\n%s", fullPath, err))
|
||||
}
|
||||
|
||||
return s
|
||||
return self
|
||||
}
|
||||
|
||||
func (s *Shell) CreateDir(path string) *Shell {
|
||||
fullPath := filepath.Join(s.dir, path)
|
||||
func (self *Shell) CreateDir(path string) *Shell {
|
||||
fullPath := filepath.Join(self.dir, path)
|
||||
if err := os.MkdirAll(fullPath, 0o755); err != nil {
|
||||
panic(fmt.Sprintf("error creating directory: %s\n%s", fullPath, err))
|
||||
self.fail(fmt.Sprintf("error creating directory: %s\n%s", fullPath, err))
|
||||
}
|
||||
|
||||
return s
|
||||
return self
|
||||
}
|
||||
|
||||
func (s *Shell) UpdateFile(path string, content string) *Shell {
|
||||
fullPath := filepath.Join(s.dir, path)
|
||||
func (self *Shell) UpdateFile(path string, content string) *Shell {
|
||||
fullPath := filepath.Join(self.dir, path)
|
||||
err := os.WriteFile(fullPath, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error updating file: %s\n%s", fullPath, err))
|
||||
self.fail(fmt.Sprintf("error updating file: %s\n%s", fullPath, err))
|
||||
}
|
||||
|
||||
return s
|
||||
return self
|
||||
}
|
||||
|
||||
func (s *Shell) NewBranch(name string) *Shell {
|
||||
return s.RunCommand("git checkout -b " + name)
|
||||
func (self *Shell) NewBranch(name string) *Shell {
|
||||
return self.RunCommand("git checkout -b " + name)
|
||||
}
|
||||
|
||||
func (s *Shell) Checkout(name string) *Shell {
|
||||
return s.RunCommand("git checkout " + name)
|
||||
func (self *Shell) Checkout(name string) *Shell {
|
||||
return self.RunCommand("git checkout " + name)
|
||||
}
|
||||
|
||||
func (s *Shell) Merge(name string) *Shell {
|
||||
return s.RunCommand("git merge --commit --no-ff " + name)
|
||||
func (self *Shell) Merge(name string) *Shell {
|
||||
return self.RunCommand("git merge --commit --no-ff " + name)
|
||||
}
|
||||
|
||||
func (s *Shell) GitAdd(path string) *Shell {
|
||||
return s.RunCommand(fmt.Sprintf("git add \"%s\"", path))
|
||||
func (self *Shell) GitAdd(path string) *Shell {
|
||||
return self.RunCommand(fmt.Sprintf("git add \"%s\"", path))
|
||||
}
|
||||
|
||||
func (s *Shell) GitAddAll() *Shell {
|
||||
return s.RunCommand("git add -A")
|
||||
func (self *Shell) GitAddAll() *Shell {
|
||||
return self.RunCommand("git add -A")
|
||||
}
|
||||
|
||||
func (s *Shell) Commit(message string) *Shell {
|
||||
return s.RunCommand(fmt.Sprintf("git commit -m \"%s\"", message))
|
||||
func (self *Shell) Commit(message string) *Shell {
|
||||
return self.RunCommand(fmt.Sprintf("git commit -m \"%s\"", message))
|
||||
}
|
||||
|
||||
func (s *Shell) EmptyCommit(message string) *Shell {
|
||||
return s.RunCommand(fmt.Sprintf("git commit --allow-empty -m \"%s\"", message))
|
||||
func (self *Shell) EmptyCommit(message string) *Shell {
|
||||
return self.RunCommand(fmt.Sprintf("git commit --allow-empty -m \"%s\"", message))
|
||||
}
|
||||
|
||||
// convenience method for creating a file and adding it
|
||||
func (s *Shell) CreateFileAndAdd(fileName string, fileContents string) *Shell {
|
||||
return s.
|
||||
func (self *Shell) CreateFileAndAdd(fileName string, fileContents string) *Shell {
|
||||
return self.
|
||||
CreateFile(fileName, fileContents).
|
||||
GitAdd(fileName)
|
||||
}
|
||||
|
||||
// convenience method for updating a file and adding it
|
||||
func (s *Shell) UpdateFileAndAdd(fileName string, fileContents string) *Shell {
|
||||
return s.
|
||||
func (self *Shell) UpdateFileAndAdd(fileName string, fileContents string) *Shell {
|
||||
return self.
|
||||
UpdateFile(fileName, fileContents).
|
||||
GitAdd(fileName)
|
||||
}
|
||||
@@ -109,19 +149,53 @@ func (s *Shell) UpdateFileAndAdd(fileName string, fileContents string) *Shell {
|
||||
// creates commits 01, 02, 03, ..., n with a new file in each
|
||||
// The reason for padding with zeroes is so that it's easier to do string
|
||||
// matches on the commit messages when there are many of them
|
||||
func (s *Shell) CreateNCommits(n int) *Shell {
|
||||
func (self *Shell) CreateNCommits(n int) *Shell {
|
||||
for i := 1; i <= n; i++ {
|
||||
s.CreateFileAndAdd(
|
||||
self.CreateFileAndAdd(
|
||||
fmt.Sprintf("file%02d.txt", i),
|
||||
fmt.Sprintf("file%02d content", i),
|
||||
).
|
||||
Commit(fmt.Sprintf("commit %02d", i))
|
||||
}
|
||||
|
||||
return s
|
||||
return self
|
||||
}
|
||||
|
||||
func (s *Shell) StashWithMessage(message string) *Shell {
|
||||
s.RunCommand(fmt.Sprintf(`git stash -m "%s"`, message))
|
||||
return s
|
||||
func (self *Shell) StashWithMessage(message string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf(`git stash -m "%s"`, message))
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *Shell) SetConfig(key string, value string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf(`git config --local "%s" %s`, key, value))
|
||||
return self
|
||||
}
|
||||
|
||||
// creates a clone of the repo in a sibling directory and adds the clone
|
||||
// as a remote, then fetches it.
|
||||
func (self *Shell) CloneIntoRemote(name string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf("git clone --bare . ../%s", name))
|
||||
self.RunCommand(fmt.Sprintf("git remote add %s ../%s", name, name))
|
||||
self.RunCommand(fmt.Sprintf("git fetch %s", name))
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// e.g. branch: 'master', upstream: 'origin/master'
|
||||
func (self *Shell) SetBranchUpstream(branch string, upstream string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf("git branch --set-upstream-to=%s %s", upstream, branch))
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *Shell) RemoveRemoteBranch(remoteName string, branch string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf("git -C ../%s branch -d %s", remoteName, branch))
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *Shell) HardReset(ref string) *Shell {
|
||||
self.RunCommand(fmt.Sprintf("git reset --hard %s", ref))
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/slices"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// This creates and compares integration test snapshots.
|
||||
|
||||
type (
|
||||
logf func(format string, formatArgs ...interface{})
|
||||
)
|
||||
|
||||
func HandleSnapshots(paths Paths, logf logf, test *IntegrationTest, mode Mode) error {
|
||||
return NewSnapshotter(paths, logf, test, mode).
|
||||
handleSnapshots()
|
||||
}
|
||||
|
||||
type Snapshotter struct {
|
||||
paths Paths
|
||||
logf logf
|
||||
test *IntegrationTest
|
||||
mode Mode
|
||||
}
|
||||
|
||||
func NewSnapshotter(
|
||||
paths Paths,
|
||||
logf logf,
|
||||
test *IntegrationTest,
|
||||
mode Mode,
|
||||
) *Snapshotter {
|
||||
return &Snapshotter{
|
||||
paths: paths,
|
||||
logf: logf,
|
||||
test: test,
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Snapshotter) handleSnapshots() error {
|
||||
switch self.mode {
|
||||
case UPDATE_SNAPSHOT:
|
||||
return self.handleUpdate()
|
||||
case CHECK_SNAPSHOT:
|
||||
return self.handleCheck()
|
||||
case ASK_TO_UPDATE_SNAPSHOT:
|
||||
return self.handleAskToUpdate()
|
||||
case SANDBOX:
|
||||
self.logf("Sandbox session exited")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Snapshotter) handleUpdate() error {
|
||||
if err := self.updateSnapshot(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.logf("Test passed: %s", self.test.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Snapshotter) handleCheck() error {
|
||||
self.logf("Comparing snapshots")
|
||||
if err := self.compareSnapshots(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.logf("Test passed: %s", self.test.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Snapshotter) handleAskToUpdate() error {
|
||||
if _, err := os.Stat(self.paths.Expected()); os.IsNotExist(err) {
|
||||
if err := self.updateSnapshot(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.logf("No existing snapshot found for %s. Created snapshot.", self.test.Name())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logf("Comparing snapshots...")
|
||||
if err := self.compareSnapshots(); err != nil {
|
||||
self.logf("%s", err)
|
||||
|
||||
// prompt user whether to update the snapshot (Y/N)
|
||||
if promptUserToUpdateSnapshot() {
|
||||
if err := self.updateSnapshot(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.logf("Snapshot updated: %s", self.test.Name())
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
self.logf("Test passed: %s", self.test.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Snapshotter) updateSnapshot() error {
|
||||
// create/update snapshot
|
||||
err := oscommands.CopyDir(self.paths.Actual(), self.paths.Expected())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := renameSpecialPaths(self.paths.Expected()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Snapshotter) compareSnapshots() error {
|
||||
// there are a couple of reasons we're not generating the snapshot in expectedDir directly:
|
||||
// Firstly we don't want to have to revert our .git file back to .git_keep.
|
||||
// Secondly, the act of calling git commands like 'git status' actually changes the index
|
||||
// for some reason, and we don't want to leave your lazygit working tree dirty as a result.
|
||||
expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", self.test.Name())
|
||||
err := oscommands.CopyDir(self.paths.Expected(), expectedDirCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(expectedDirCopy)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := restoreSpecialPaths(expectedDirCopy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateSameRepos(expectedDirCopy, self.paths.Actual())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// iterate through each repo in the expected dir and comparet to the corresponding repo in the actual dir
|
||||
expectedFiles, err := ioutil.ReadDir(expectedDirCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range expectedFiles {
|
||||
if !f.IsDir() {
|
||||
return errors.New("unexpected file (as opposed to directory) in integration test 'expected' directory")
|
||||
}
|
||||
|
||||
// get corresponding file name from actual dir
|
||||
actualRepoPath := filepath.Join(self.paths.Actual(), f.Name())
|
||||
expectedRepoPath := filepath.Join(expectedDirCopy, f.Name())
|
||||
|
||||
actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if expectedRepo != actualRepo {
|
||||
// get the log file and print it
|
||||
bytes, err := os.ReadFile(filepath.Join(self.paths.Config(), "development.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.logf("%s", string(bytes))
|
||||
|
||||
return errors.New(getDiff(f.Name(), expectedRepo, actualRepo))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptUserToUpdateSnapshot() bool {
|
||||
fmt.Println("Test failed. Update snapshot? (y/n)")
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
return input == "y"
|
||||
}
|
||||
|
||||
func generateSnapshots(actualDir string, expectedDir string) (string, string, error) {
|
||||
actual, err := generateSnapshot(actualDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
expected, err := generateSnapshot(expectedDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return actual, expected, nil
|
||||
}
|
||||
|
||||
// note that we don't actually store this snapshot in the lazygit repo.
|
||||
// Instead we store the whole expected git repo of our test, so that
|
||||
// we can easily change what we want to compare without needing to regenerate
|
||||
// snapshots for each test.
|
||||
func generateSnapshot(dir string) (string, error) {
|
||||
osCommand := oscommands.NewDummyOSCommand()
|
||||
|
||||
_, err := os.Stat(filepath.Join(dir, ".git"))
|
||||
if err != nil {
|
||||
return "git directory not found", nil
|
||||
}
|
||||
|
||||
snapshot := ""
|
||||
|
||||
cmdStrs := []string{
|
||||
`remote show -n origin`, // remote branches
|
||||
// TODO: find a way to bring this back without breaking tests
|
||||
// `ls-remote origin`,
|
||||
`status`, // file tree
|
||||
`log --pretty=%B|%an|%ae -p -1`, // log
|
||||
`tag -n`, // tags
|
||||
`stash list`, // stash
|
||||
`submodule foreach 'git status'`, // submodule status
|
||||
`submodule foreach 'git log --pretty=%B -p -1'`, // submodule log
|
||||
`submodule foreach 'git tag -n'`, // submodule tags
|
||||
`submodule foreach 'git stash list'`, // submodule stash
|
||||
}
|
||||
|
||||
for _, cmdStr := range cmdStrs {
|
||||
// ignoring error for now. If there's an error it could be that there are no results
|
||||
output, _ := osCommand.Cmd.New(fmt.Sprintf("git -C %s %s", dir, cmdStr)).RunWithOutput()
|
||||
|
||||
snapshot += fmt.Sprintf("git %s:\n%s\n", cmdStr, output)
|
||||
}
|
||||
|
||||
snapshot += "files in repo:\n"
|
||||
err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.IsDir() {
|
||||
if f.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot += fmt.Sprintf("path: %s\ncontent:\n%s\n", relativePath, string(bytes))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func getPathsToRename(dir string, needle string, contains string) []string {
|
||||
pathsToRename := []string{}
|
||||
|
||||
err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Name() == needle && (contains == "" || strings.Contains(path, contains)) {
|
||||
pathsToRename = append(pathsToRename, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return pathsToRename
|
||||
}
|
||||
|
||||
var specialPathMappings = []struct{ original, new, contains string }{
|
||||
// git refuses to track .git or .gitmodules in subdirectories so we need to rename them
|
||||
{".git", ".git_keep", ""},
|
||||
{".gitmodules", ".gitmodules_keep", ""},
|
||||
// we also need git to ignore the contents of our test gitignore files so that
|
||||
// we actually commit files that are ignored within the test.
|
||||
{".gitignore", "lg_ignore_file", ""},
|
||||
// this is the .git/info/exclude file. We're being a little more specific here
|
||||
// so that we don't accidentally mess with some other file named 'exclude' in the test.
|
||||
{"exclude", "lg_exclude_file", ".git/info/exclude"},
|
||||
}
|
||||
|
||||
func renameSpecialPaths(dir string) error {
|
||||
for _, specialPath := range specialPathMappings {
|
||||
for _, path := range getPathsToRename(dir, specialPath.original, specialPath.contains) {
|
||||
err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.new))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreSpecialPaths(dir string) error {
|
||||
for _, specialPath := range specialPathMappings {
|
||||
for _, path := range getPathsToRename(dir, specialPath.new, specialPath.contains) {
|
||||
err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.original))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validates that the actual and expected dirs have the same repo names (doesn't actually check the contents of the repos)
|
||||
func validateSameRepos(expectedDir string, actualDir string) error {
|
||||
// iterate through each repo in the expected dir and compare to the corresponding repo in the actual dir
|
||||
expectedFiles, err := ioutil.ReadDir(expectedDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var actualFiles []os.FileInfo
|
||||
actualFiles, err = ioutil.ReadDir(actualDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedFileNames := slices.Map(expectedFiles, getFileName)
|
||||
actualFileNames := slices.Map(actualFiles, getFileName)
|
||||
if !slices.Equal(expectedFileNames, actualFileNames) {
|
||||
return fmt.Errorf("expected and actual repo dirs do not match: expected: %s, actual: %s", expectedFileNames, actualFileNames)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileName(f os.FileInfo) string {
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func getDiff(prefix string, expected string, actual string) string {
|
||||
mockT := &MockTestingT{}
|
||||
assert.Equal(mockT, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
|
||||
return mockT.err
|
||||
}
|
||||
|
||||
type MockTestingT struct {
|
||||
err string
|
||||
}
|
||||
|
||||
func (self *MockTestingT) Errorf(format string, args ...interface{}) {
|
||||
self.err += fmt.Sprintf(format, args...)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -24,9 +25,7 @@ type IntegrationTest struct {
|
||||
setupRepo func(shell *Shell)
|
||||
setupConfig func(config *config.AppConfig)
|
||||
run func(
|
||||
shell *Shell,
|
||||
input *Input,
|
||||
assert *Assert,
|
||||
testDriver *TestDriver,
|
||||
keys config.KeybindingConfig,
|
||||
)
|
||||
}
|
||||
@@ -41,7 +40,7 @@ type NewIntegrationTestArgs struct {
|
||||
// takes a config and mutates. The mutated context will end up being passed to the gui
|
||||
SetupConfig func(config *config.AppConfig)
|
||||
// runs the test
|
||||
Run func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig)
|
||||
Run func(t *TestDriver, keys config.KeybindingConfig)
|
||||
// additional args passed to lazygit
|
||||
ExtraCmdArgs string
|
||||
// for when a test is flakey
|
||||
@@ -91,18 +90,20 @@ func (self *IntegrationTest) SetupRepo(shell *Shell) {
|
||||
self.setupRepo(shell)
|
||||
}
|
||||
|
||||
// I want access to all contexts, the model, the ability to press a key, the ability to log,
|
||||
func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
|
||||
shell := NewShell("/tmp/lazygit-test")
|
||||
assert := NewAssert(gui)
|
||||
keys := gui.Keys()
|
||||
input := NewInput(gui, keys, assert, KeyPressDelay())
|
||||
// we pass the --pass arg to lazygit when running an integration test, and that
|
||||
// ends up stored in the following env var
|
||||
repoPath := env.GetGitWorkTreeEnv()
|
||||
|
||||
self.run(shell, input, assert, keys)
|
||||
shell := NewShell(repoPath, func(errorMsg string) { gui.Fail(errorMsg) })
|
||||
keys := gui.Keys()
|
||||
testDriver := NewTestDriver(gui, shell, keys, KeyPressDelay())
|
||||
|
||||
self.run(testDriver, keys)
|
||||
|
||||
if KeyPressDelay() > 0 {
|
||||
// the dev would want to see the final state if they're running in slow mode
|
||||
input.Wait(2000)
|
||||
testDriver.Wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
)
|
||||
|
||||
type TestDriver struct {
|
||||
gui integrationTypes.GuiDriver
|
||||
keys config.KeybindingConfig
|
||||
pushKeyDelay int
|
||||
*assertionHelper
|
||||
shell *Shell
|
||||
}
|
||||
|
||||
func NewTestDriver(gui integrationTypes.GuiDriver, shell *Shell, keys config.KeybindingConfig, pushKeyDelay int) *TestDriver {
|
||||
return &TestDriver{
|
||||
gui: gui,
|
||||
keys: keys,
|
||||
pushKeyDelay: pushKeyDelay,
|
||||
assertionHelper: &assertionHelper{gui: gui},
|
||||
shell: shell,
|
||||
}
|
||||
}
|
||||
|
||||
// key is something like 'w' or '<space>'. It's best not to pass a direct value,
|
||||
// but instead to go through the default user config to get a more meaningful key name
|
||||
func (self *TestDriver) press(keyStr string) {
|
||||
self.Wait(self.pushKeyDelay)
|
||||
|
||||
self.gui.PressKey(keyStr)
|
||||
}
|
||||
|
||||
func (self *TestDriver) typeContent(content string) {
|
||||
for _, char := range content {
|
||||
self.press(string(char))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *TestDriver) Actions() *Actions {
|
||||
return &Actions{t: self}
|
||||
}
|
||||
|
||||
// for when you want to allow lazygit to process something before continuing
|
||||
func (self *TestDriver) Wait(milliseconds int) {
|
||||
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (self *TestDriver) LogUI(message string) {
|
||||
self.gui.LogUI(message)
|
||||
}
|
||||
|
||||
func (self *TestDriver) Log(message string) {
|
||||
self.gui.LogUI(message)
|
||||
}
|
||||
|
||||
// allows the user to run shell commands during the test to emulate background activity
|
||||
func (self *TestDriver) Shell() *Shell {
|
||||
return self.shell
|
||||
}
|
||||
|
||||
// this will look for a list item in the current panel and if it finds it, it will
|
||||
// enter the keypresses required to navigate to it.
|
||||
// The test will fail if:
|
||||
// - the user is not in a list item
|
||||
// - no list item is found containing the given text
|
||||
// - multiple list items are found containing the given text in the initial page of items
|
||||
//
|
||||
// NOTE: this currently assumes that ViewBufferLines returns all the lines that can be accessed.
|
||||
// If this changes in future, we'll need to update this code to first attempt to find the item
|
||||
// in the current page and failing that, jump to the top of the view and iterate through all of it,
|
||||
// looking for the item.
|
||||
func (self *TestDriver) navigateToListItem(matcher *matcher) {
|
||||
self.inListContext()
|
||||
|
||||
currentContext := self.gui.CurrentContext().(types.IListContext)
|
||||
|
||||
view := currentContext.GetView()
|
||||
|
||||
var matchIndex int
|
||||
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
matchIndex = -1
|
||||
var matches []string
|
||||
lines := view.ViewBufferLines()
|
||||
// first we look for a duplicate on the current screen. We won't bother looking beyond that though.
|
||||
for i, line := range lines {
|
||||
ok, _ := matcher.test(line)
|
||||
if ok {
|
||||
matches = append(matches, line)
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return false, fmt.Sprintf("Found %d matches for `%s`, expected only a single match. Matching lines:\n%s", len(matches), matcher.name(), strings.Join(matches, "\n"))
|
||||
} else if len(matches) == 0 {
|
||||
return false, fmt.Sprintf("Could not find item matching: %s. Lines:\n%s", matcher.name(), strings.Join(lines, "\n"))
|
||||
} else {
|
||||
return true, ""
|
||||
}
|
||||
})
|
||||
|
||||
selectedLineIdx := view.SelectedLineIdx()
|
||||
if selectedLineIdx == matchIndex {
|
||||
self.Views().current().SelectedLine(matcher)
|
||||
return
|
||||
}
|
||||
if selectedLineIdx < matchIndex {
|
||||
for i := selectedLineIdx; i < matchIndex; i++ {
|
||||
self.Views().current().SelectNextItem()
|
||||
}
|
||||
self.Views().current().SelectedLine(matcher)
|
||||
return
|
||||
} else {
|
||||
for i := selectedLineIdx; i > matchIndex; i-- {
|
||||
self.Views().current().SelectPreviousItem()
|
||||
}
|
||||
self.Views().current().SelectedLine(matcher)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (self *TestDriver) inListContext() {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
currentContext := self.gui.CurrentContext()
|
||||
_, ok := currentContext.(types.IListContext)
|
||||
return ok, fmt.Sprintf("Expected current context to be a list context, but got %s", currentContext.GetKey())
|
||||
})
|
||||
}
|
||||
|
||||
// for making assertions on lazygit views
|
||||
func (self *TestDriver) Views() *Views {
|
||||
return &Views{t: self}
|
||||
}
|
||||
|
||||
// for interacting with popups
|
||||
func (self *TestDriver) ExpectPopup() *Popup {
|
||||
return &Popup{t: self}
|
||||
}
|
||||
|
||||
func (self *TestDriver) ExpectToast(matcher *matcher) {
|
||||
self.Views().AppStatus().Content(matcher)
|
||||
}
|
||||
|
||||
func (self *TestDriver) ExpectClipboard(matcher *matcher) {
|
||||
self.assertWithRetries(func() (bool, string) {
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
return false, "Error occured when reading from clipboard: " + err.Error()
|
||||
}
|
||||
ok, _ := matcher.test(text)
|
||||
return ok, fmt.Sprintf("Expected clipboard to match %s, but got %s", matcher.name(), text)
|
||||
})
|
||||
}
|
||||
|
||||
// for making assertions through git itself
|
||||
func (self *TestDriver) Git() *Git {
|
||||
return &Git{assertionHelper: self.assertionHelper, shell: self.shell}
|
||||
}
|
||||
|
||||
// for making assertions on the file system
|
||||
func (self *TestDriver) FileSystem() *FileSystem {
|
||||
return &FileSystem{assertionHelper: self.assertionHelper}
|
||||
}
|
||||
|
||||
// for when you just want to fail the test yourself.
|
||||
// This runs callbacks to ensure we render the error after closing the gui.
|
||||
func (self *TestDriver) Fail(message string) {
|
||||
self.assertionHelper.fail(message)
|
||||
}
|
||||
@@ -30,10 +30,6 @@ func (self *fakeGuiDriver) CurrentContext() types.Context {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) Model() *types.Model {
|
||||
return &types.Model{Commits: []*models.Commit{}}
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) Fail(message string) {
|
||||
self.failureMessage = message
|
||||
}
|
||||
@@ -60,26 +56,11 @@ func (self *fakeGuiDriver) View(viewName string) *gocui.View {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAssertionFailure(t *testing.T) {
|
||||
test := NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: unitTestDescription,
|
||||
Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
|
||||
input.PressKeys("a")
|
||||
input.PressKeys("b")
|
||||
assert.CommitCount(2)
|
||||
},
|
||||
})
|
||||
driver := &fakeGuiDriver{}
|
||||
test.Run(driver)
|
||||
assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys)
|
||||
assert.Equal(t, "Expected 2 commits present, but got 0", driver.failureMessage)
|
||||
}
|
||||
|
||||
func TestManualFailure(t *testing.T) {
|
||||
test := NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: unitTestDescription,
|
||||
Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
|
||||
assert.Fail("blah")
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Fail("blah")
|
||||
},
|
||||
})
|
||||
driver := &fakeGuiDriver{}
|
||||
@@ -90,10 +71,9 @@ func TestManualFailure(t *testing.T) {
|
||||
func TestSuccess(t *testing.T) {
|
||||
test := NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: unitTestDescription,
|
||||
Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
|
||||
input.PressKeys("a")
|
||||
input.PressKeys("b")
|
||||
assert.CommitCount(0)
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.press("a")
|
||||
t.press("b")
|
||||
},
|
||||
})
|
||||
driver := &fakeGuiDriver{}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
type ViewDriver struct {
|
||||
// context is prepended to any error messages e.g. 'context: "current view"'
|
||||
context string
|
||||
getView func() *gocui.View
|
||||
t *TestDriver
|
||||
}
|
||||
|
||||
// asserts that the view has the expected title
|
||||
func (self *ViewDriver) Title(expected *matcher) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
actual := self.getView().Title
|
||||
return expected.context(fmt.Sprintf("%s title", self.context)).test(actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that the view has lines matching the given matchers. So if three matchers
|
||||
// are passed, we only check the first three lines of the view.
|
||||
// This method is convenient when you have a list of commits but you only want to
|
||||
// assert on the first couple of commits.
|
||||
func (self *ViewDriver) TopLines(matchers ...*matcher) *ViewDriver {
|
||||
if len(matchers) < 1 {
|
||||
self.t.fail("TopLines method requires at least one matcher. If you are trying to assert that there are no lines, use .IsEmpty()")
|
||||
}
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
lines := self.getView().BufferLines()
|
||||
return len(lines) >= len(matchers), fmt.Sprintf("unexpected number of lines in view. Expected at least %d, got %d", len(matchers), len(lines))
|
||||
})
|
||||
|
||||
return self.assertLines(matchers...)
|
||||
}
|
||||
|
||||
// asserts that the view has lines matching the given matchers. One matcher must be passed for each line.
|
||||
// If you only care about the top n lines, use the TopLines method instead.
|
||||
func (self *ViewDriver) Lines(matchers ...*matcher) *ViewDriver {
|
||||
self.LineCount(len(matchers))
|
||||
|
||||
return self.assertLines(matchers...)
|
||||
}
|
||||
|
||||
func (self *ViewDriver) assertLines(matchers ...*matcher) *ViewDriver {
|
||||
view := self.getView()
|
||||
|
||||
for i, matcher := range matchers {
|
||||
checkIsSelected, matcher := matcher.checkIsSelected()
|
||||
|
||||
self.t.matchString(matcher, fmt.Sprintf("Unexpected content in view '%s'.", view.Name()),
|
||||
func() string {
|
||||
return view.BufferLines()[i]
|
||||
},
|
||||
)
|
||||
|
||||
if checkIsSelected {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
lineIdx := view.SelectedLineIdx()
|
||||
return lineIdx == i, fmt.Sprintf("Unexpected selected line index in view '%s'. Expected %d, got %d", view.Name(), i, lineIdx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts on the content of the view i.e. the stuff within the view's frame.
|
||||
func (self *ViewDriver) Content(matcher *matcher) *ViewDriver {
|
||||
self.t.matchString(matcher, fmt.Sprintf("%s: Unexpected content.", self.context),
|
||||
func() string {
|
||||
return self.getView().Buffer()
|
||||
},
|
||||
)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts on the selected line of the view
|
||||
func (self *ViewDriver) SelectedLine(matcher *matcher) *ViewDriver {
|
||||
self.t.matchString(matcher, fmt.Sprintf("%s: Unexpected selected line.", self.context),
|
||||
func() string {
|
||||
return self.getView().SelectedLine()
|
||||
},
|
||||
)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts on the index of the selected line. 0 is the first index, representing the line at the top of the view.
|
||||
func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
actual := self.getView().SelectedLineIdx()
|
||||
return expected == actual, fmt.Sprintf("%s: Expected selected line index to be %d, got %d", self.context, expected, actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// focus the view (assumes the view is a side-view that can be focused via a keybinding)
|
||||
func (self *ViewDriver) Focus() *ViewDriver {
|
||||
// we can easily change focus by switching to the view's window, but this assumes that the desired view
|
||||
// is at the top of that window. So for now we'll switch to the window then assert that the desired
|
||||
// view is on top (i.e. that it's the current view).
|
||||
// If we want to support other views e.g. the tags view, we'll need to add more logic here.
|
||||
viewName := self.getView().Name()
|
||||
|
||||
// using a map rather than a slice because we might add other views which share a window index later
|
||||
windowIndexMap := map[string]int{
|
||||
"status": 0,
|
||||
"files": 1,
|
||||
"localBranches": 2,
|
||||
"commits": 3,
|
||||
"stash": 4,
|
||||
}
|
||||
|
||||
index, ok := windowIndexMap[viewName]
|
||||
if !ok {
|
||||
self.t.fail(fmt.Sprintf("Cannot focus view %s: Focus() method not implemented", viewName))
|
||||
}
|
||||
|
||||
self.t.press(self.t.keys.Universal.JumpToBlock[index])
|
||||
|
||||
// assert that we land in the expected view
|
||||
self.IsFocused()
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that the view is focused
|
||||
func (self *ViewDriver) IsFocused() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
expected := self.getView().Name()
|
||||
actual := self.t.gui.CurrentContext().GetView().Name()
|
||||
return actual == expected, fmt.Sprintf("%s: Unexpected view focused. Expected %s, got %s", self.context, expected, actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) Press(keyStr string) *ViewDriver {
|
||||
self.IsFocused()
|
||||
|
||||
self.t.press(keyStr)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// i.e. pressing down arrow
|
||||
func (self *ViewDriver) SelectNextItem() *ViewDriver {
|
||||
return self.Press(self.t.keys.Universal.NextItem)
|
||||
}
|
||||
|
||||
// i.e. pressing up arrow
|
||||
func (self *ViewDriver) SelectPreviousItem() *ViewDriver {
|
||||
return self.Press(self.t.keys.Universal.PrevItem)
|
||||
}
|
||||
|
||||
// i.e. pressing space
|
||||
func (self *ViewDriver) PressPrimaryAction() *ViewDriver {
|
||||
return self.Press(self.t.keys.Universal.Select)
|
||||
}
|
||||
|
||||
// i.e. pressing space
|
||||
func (self *ViewDriver) PressEnter() *ViewDriver {
|
||||
return self.Press(self.t.keys.Universal.Confirm)
|
||||
}
|
||||
|
||||
// i.e. pressing escape
|
||||
func (self *ViewDriver) PressEscape() *ViewDriver {
|
||||
return self.Press(self.t.keys.Universal.Return)
|
||||
}
|
||||
|
||||
func (self *ViewDriver) NavigateToListItem(matcher *matcher) *ViewDriver {
|
||||
self.IsFocused()
|
||||
|
||||
self.t.navigateToListItem(matcher)
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// returns true if the view is a list view and it contains no items
|
||||
func (self *ViewDriver) IsEmpty() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
actual := strings.TrimSpace(self.getView().Buffer())
|
||||
return actual == "", fmt.Sprintf("%s: Unexpected content in view: expected no content. Content: %s", self.context, actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) LineCount(expectedCount int) *ViewDriver {
|
||||
if expectedCount == 0 {
|
||||
return self.IsEmpty()
|
||||
}
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
lines := self.getView().BufferLines()
|
||||
return len(lines) == expectedCount, fmt.Sprintf("unexpected number of lines in view. Expected %d, got %d", expectedCount, len(lines))
|
||||
})
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
lines := self.getView().BufferLines()
|
||||
|
||||
// if the view has a single blank line (often the case) we want to treat that as having no lines
|
||||
if len(lines) == 1 && expectedCount == 1 {
|
||||
actual := strings.TrimSpace(self.getView().Buffer())
|
||||
return actual != "", "unexpected number of lines in view. Expected 1, got 0"
|
||||
}
|
||||
|
||||
return len(lines) == expectedCount, fmt.Sprintf("unexpected number of lines in view. Expected %d, got %d", expectedCount, len(lines))
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// for when you want to make some assertion unrelated to the current view
|
||||
// without breaking the method chain
|
||||
func (self *ViewDriver) Tap(f func()) *ViewDriver {
|
||||
f()
|
||||
|
||||
return self
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
type Views struct {
|
||||
t *TestDriver
|
||||
}
|
||||
|
||||
// not exporting this because I want the test to always be explicit about what
|
||||
// view it's dealing with.
|
||||
func (self *Views) current() *ViewDriver {
|
||||
return &ViewDriver{
|
||||
context: "current view",
|
||||
getView: func() *gocui.View { return self.t.gui.CurrentContext().GetView() },
|
||||
t: self.t,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Views) Main() *ViewDriver {
|
||||
return &ViewDriver{
|
||||
context: "main view",
|
||||
getView: func() *gocui.View { return self.t.gui.MainView() },
|
||||
t: self.t,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Views) Secondary() *ViewDriver {
|
||||
return &ViewDriver{
|
||||
context: "secondary view",
|
||||
getView: func() *gocui.View { return self.t.gui.SecondaryView() },
|
||||
t: self.t,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Views) byName(viewName string) *ViewDriver {
|
||||
return &ViewDriver{
|
||||
context: fmt.Sprintf("%s view", viewName),
|
||||
getView: func() *gocui.View { return self.t.gui.View(viewName) },
|
||||
t: self.t,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Views) Commits() *ViewDriver {
|
||||
return self.byName("commits")
|
||||
}
|
||||
|
||||
func (self *Views) Files() *ViewDriver {
|
||||
return self.byName("files")
|
||||
}
|
||||
|
||||
func (self *Views) Status() *ViewDriver {
|
||||
return self.byName("status")
|
||||
}
|
||||
|
||||
func (self *Views) Submodules() *ViewDriver {
|
||||
return self.byName("submodules")
|
||||
}
|
||||
|
||||
func (self *Views) Information() *ViewDriver {
|
||||
return self.byName("information")
|
||||
}
|
||||
|
||||
func (self *Views) AppStatus() *ViewDriver {
|
||||
return self.byName("appStatus")
|
||||
}
|
||||
|
||||
func (self *Views) Branches() *ViewDriver {
|
||||
return self.byName("localBranches")
|
||||
}
|
||||
|
||||
func (self *Views) RemoteBranches() *ViewDriver {
|
||||
return self.byName("remoteBranches")
|
||||
}
|
||||
|
||||
func (self *Views) Tags() *ViewDriver {
|
||||
return self.byName("tags")
|
||||
}
|
||||
|
||||
func (self *Views) ReflogCommits() *ViewDriver {
|
||||
return self.byName("reflogCommits")
|
||||
}
|
||||
|
||||
func (self *Views) SubCommits() *ViewDriver {
|
||||
return self.byName("subCommits")
|
||||
}
|
||||
|
||||
func (self *Views) CommitFiles() *ViewDriver {
|
||||
return self.byName("commitFiles")
|
||||
}
|
||||
|
||||
func (self *Views) Stash() *ViewDriver {
|
||||
return self.byName("stash")
|
||||
}
|
||||
|
||||
func (self *Views) Staging() *ViewDriver {
|
||||
return self.byName("staging")
|
||||
}
|
||||
|
||||
func (self *Views) StagingSecondary() *ViewDriver {
|
||||
return self.byName("stagingSecondary")
|
||||
}
|
||||
|
||||
func (self *Views) Menu() *ViewDriver {
|
||||
return self.byName("menu")
|
||||
}
|
||||
|
||||
func (self *Views) Confirmation() *ViewDriver {
|
||||
return self.byName("confirmation")
|
||||
}
|
||||
|
||||
func (self *Views) CommitMessage() *ViewDriver {
|
||||
return self.byName("commitMessage")
|
||||
}
|
||||
|
||||
func (self *Views) Suggestions() *ViewDriver {
|
||||
return self.byName("suggestions")
|
||||
}
|
||||
|
||||
func (self *Views) MergeConflicts() *ViewDriver {
|
||||
return self.byName("mergeConflicts")
|
||||
}
|
||||
@@ -14,73 +14,46 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
CreateNCommits(10)
|
||||
},
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
Run: func(
|
||||
shell *Shell,
|
||||
input *Input,
|
||||
assert *Assert,
|
||||
keys config.KeybindingConfig,
|
||||
) {
|
||||
viewBisectOptions := func() {
|
||||
input.PressKeys(keys.Commits.ViewBisectOptions)
|
||||
assert.InMenu()
|
||||
}
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
markCommitAsBad := func() {
|
||||
viewBisectOptions()
|
||||
assert.MatchSelectedLine(Contains("bad"))
|
||||
t.Views().Commits().
|
||||
Press(keys.Commits.ViewBisectOptions)
|
||||
|
||||
input.Confirm()
|
||||
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`mark .* as bad`)).Confirm()
|
||||
}
|
||||
|
||||
markCommitAsGood := func() {
|
||||
viewBisectOptions()
|
||||
assert.MatchSelectedLine(Contains("bad"))
|
||||
input.NextItem()
|
||||
assert.MatchSelectedLine(Contains("good"))
|
||||
t.Views().Commits().
|
||||
Press(keys.Commits.ViewBisectOptions)
|
||||
|
||||
input.Confirm()
|
||||
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`mark .* as good`)).Confirm()
|
||||
}
|
||||
|
||||
assert.AtLeastOneCommit()
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("commit 10")).
|
||||
NavigateToListItem(Contains("commit 09")).
|
||||
Tap(func() {
|
||||
markCommitAsBad()
|
||||
|
||||
input.SwitchToCommitsWindow()
|
||||
t.Views().Information().Content(Contains("bisecting"))
|
||||
}).
|
||||
SelectedLine(Contains("<-- bad")).
|
||||
NavigateToListItem(Contains("commit 02")).
|
||||
Tap(markCommitAsGood).
|
||||
// lazygit will land us in the commit between our good and bad commits.
|
||||
SelectedLine(Contains("commit 05").Contains("<-- current")).
|
||||
Tap(markCommitAsBad).
|
||||
SelectedLine(Contains("commit 04").Contains("<-- current")).
|
||||
Tap(func() {
|
||||
markCommitAsGood()
|
||||
|
||||
assert.MatchSelectedLine(Contains("commit 10"))
|
||||
// commit 5 is the culprit because we marked 4 as good and 5 as bad.
|
||||
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm()
|
||||
}).
|
||||
IsFocused().
|
||||
Content(Contains("commit 04"))
|
||||
|
||||
input.NavigateToListItemContainingText("commit 09")
|
||||
|
||||
markCommitAsBad()
|
||||
|
||||
assert.MatchViewContent("information", Contains("bisecting"))
|
||||
|
||||
assert.CurrentViewName("commits")
|
||||
assert.MatchSelectedLine(Contains("<-- bad"))
|
||||
|
||||
input.NavigateToListItemContainingText("commit 02")
|
||||
|
||||
markCommitAsGood()
|
||||
|
||||
// lazygit will land us in the comit between our good and bad commits.
|
||||
assert.CurrentViewName("commits")
|
||||
assert.MatchSelectedLine(Contains("commit 05"))
|
||||
assert.MatchSelectedLine(Contains("<-- current"))
|
||||
|
||||
markCommitAsBad()
|
||||
|
||||
assert.CurrentViewName("commits")
|
||||
assert.MatchSelectedLine(Contains("commit 04"))
|
||||
assert.MatchSelectedLine(Contains("<-- current"))
|
||||
|
||||
markCommitAsGood()
|
||||
|
||||
assert.InAlert()
|
||||
assert.MatchCurrentViewContent(Contains("Bisect complete!"))
|
||||
// commit 5 is the culprit because we marked 4 as good and 5 as bad.
|
||||
assert.MatchCurrentViewContent(Contains("commit 05"))
|
||||
assert.MatchCurrentViewContent(Contains("Do you want to reset"))
|
||||
input.Confirm()
|
||||
|
||||
assert.CurrentViewName("commits")
|
||||
assert.MatchCurrentViewContent(Contains("commit 04"))
|
||||
assert.MatchViewContent("information", NotContains("bisecting"))
|
||||
t.Views().Information().Content(DoesNotContain("bisecting"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,52 +18,29 @@ var FromOtherBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
RunCommand("git bisect start other~2 other~5")
|
||||
},
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
Run: func(
|
||||
shell *Shell,
|
||||
input *Input,
|
||||
assert *Assert,
|
||||
keys config.KeybindingConfig,
|
||||
) {
|
||||
viewBisectOptions := func() {
|
||||
input.PressKeys(keys.Commits.ViewBisectOptions)
|
||||
assert.InMenu()
|
||||
}
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Information().Content(Contains("bisecting"))
|
||||
|
||||
markCommitAsGood := func() {
|
||||
viewBisectOptions()
|
||||
assert.MatchSelectedLine(Contains("bad"))
|
||||
input.NextItem()
|
||||
assert.MatchSelectedLine(Contains("good"))
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
TopLines(
|
||||
MatchesRegexp(`<-- bad.*commit 08`),
|
||||
MatchesRegexp(`<-- current.*commit 07`),
|
||||
MatchesRegexp(`\?.*commit 06`),
|
||||
MatchesRegexp(`<-- good.*commit 05`),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Commits.ViewBisectOptions).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`mark .* as good`)).Confirm()
|
||||
|
||||
input.Confirm()
|
||||
}
|
||||
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 08.*Do you want to reset")).Confirm()
|
||||
|
||||
assert.MatchViewContent("information", Contains("bisecting"))
|
||||
|
||||
assert.AtLeastOneCommit()
|
||||
|
||||
input.SwitchToCommitsWindow()
|
||||
|
||||
assert.MatchSelectedLine(Contains("<-- bad"))
|
||||
assert.MatchSelectedLine(Contains("commit 08"))
|
||||
|
||||
input.NextItem()
|
||||
assert.MatchSelectedLine(Contains("<-- current"))
|
||||
assert.MatchSelectedLine(Contains("commit 07"))
|
||||
|
||||
markCommitAsGood()
|
||||
|
||||
assert.InAlert()
|
||||
assert.MatchCurrentViewContent(Contains("Bisect complete!"))
|
||||
assert.MatchCurrentViewContent(Contains("commit 08"))
|
||||
assert.MatchCurrentViewContent(Contains("Do you want to reset"))
|
||||
input.Confirm()
|
||||
|
||||
assert.MatchViewContent("information", NotContains("bisecting"))
|
||||
|
||||
// back in master branch which just had the one commit
|
||||
assert.CurrentViewName("commits")
|
||||
assert.CommitCount(1)
|
||||
assert.MatchSelectedLine(Contains("only commit on master"))
|
||||
t.Views().Information().Content(DoesNotContain("bisecting"))
|
||||
}).
|
||||
// back in master branch which just had the one commit
|
||||
Lines(
|
||||
Contains("only commit on master"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -17,23 +17,24 @@ var CheckoutByName = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Checkout("master").
|
||||
EmptyCommit("blah")
|
||||
},
|
||||
Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
|
||||
input.SwitchToBranchesWindow()
|
||||
assert.CurrentViewName("localBranches")
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("@"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.CheckoutBranchByName).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Prompt().Title(Equals("Branch name:")).Type("new-branch").Confirm()
|
||||
|
||||
assert.MatchSelectedLine(Contains("master"))
|
||||
input.NextItem()
|
||||
assert.MatchSelectedLine(Contains("@"))
|
||||
input.PressKeys(keys.Branches.CheckoutBranchByName)
|
||||
assert.InPrompt()
|
||||
assert.MatchCurrentViewTitle(Equals("Branch name:"))
|
||||
input.Type("new-branch")
|
||||
input.Confirm()
|
||||
assert.InAlert()
|
||||
assert.MatchCurrentViewContent(Equals("Branch not found. Create a new branch named new-branch?"))
|
||||
input.Confirm()
|
||||
|
||||
assert.CurrentViewName("localBranches")
|
||||
assert.MatchSelectedLine(Contains("new-branch"))
|
||||
t.ExpectPopup().Alert().Title(Equals("Branch not found")).Content(Equals("Branch not found. Create a new branch named new-branch?")).Confirm()
|
||||
}).
|
||||
Lines(
|
||||
MatchesRegexp(`\*.*new-branch`).IsSelected(),
|
||||
Contains("master"),
|
||||
Contains("@"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user