Merge branch 'master' into f/open-in-terminal

This commit is contained in:
Ramon Vermeulen 2026-06-29 08:17:50 +02:00 committed by GitHub
commit 8de0f12199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 3674 additions and 676 deletions

2
.gitattributes vendored
View file

@ -1,3 +1,3 @@
*.go text
*.go text eol=lf
*.md text eol=lf
*.json text eol=lf

14
.vscode/tasks.json vendored
View file

@ -24,7 +24,7 @@
{
"label": "Run current file integration test",
"type": "shell",
"command": "go run cmd/integration_test/main.go cli ${relativeFile}",
"command": "just e2e ${relativeFile}",
"problemMatcher": [],
"group": {
"kind": "test",
@ -61,18 +61,6 @@
"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
}
},
{
"label": "Sync tests list",
"type": "shell",

View file

@ -21,8 +21,9 @@ Windows box has only `just`).
- `just format``gofumpt -l -w .`. Run before every commit.
- `just build` — build the binary.
- `just unit-test``go test ./... -short`.
- `just e2e-all` — run all integration tests headlessly (`just e2e <name>` runs a
single one with a visible UI).
- `just e2e` — run all integration tests headlessly; `just e2e <name>` runs a
single one headlessly too. `just e2e-cli <name>` runs one with a visible UI
(most useful with `--sandbox` or `--slow`).
- `just lint` — run golangci-lint.
## When to commit
@ -45,7 +46,7 @@ while still being meaningful and self-contained.
- **Every commit must compile and pass all tests.** No "WIP" commits, no
commits that leave the tree broken and rely on a follow-up to fix it.
- **Every commit must be `gofumpt`-formatted.** Run `make format` before
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Commit messages explain _why_, not _what_.** The diff already shows what
changed; the message should capture the motivation, the constraint, or the
@ -137,6 +138,22 @@ commit. If you have two independent refinements for the same target, make
two separate fixups. Reviewability of the intermediate state matters even
when the end state after autosquash would be identical.
## Surface mid-implementation decisions; decide them together
Planning can't anticipate everything. When a decision surfaces while you're
implementing — a design choice, a tradeoff, a scope cut, a "this turned out
harder than expected, so maybe X" — don't quietly make the call and keep
going, even if you have a clear recommendation and even if the call seems
small. Stop, lay out the options and your recommendation, and let me weigh in.
I want to make these calls _with_ you, not discover them after the fact in the
diff.
This isn't a request to stop and ask about every trivial detail; obvious
mechanical choices with one sensible answer don't need a checkpoint. It's about
genuine forks — the ones where a reasonable person might pick differently, or
where you'd be trading away something the plan assumed (scope, UX, performance,
reload behavior, …). When in doubt, surface it.
## Prefer the cleaner design over the smaller diff
When a task could be implemented either by tacking onto existing code or by
@ -235,6 +252,30 @@ keep the call site fluent.
Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure
messages are more useful and the intent is clearer at a glance.
## Translatable strings use Go templates, not `%s`
Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable
strings — the fields of `TranslationSet` and `Actions` in
`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with
`utils.ResolvePlaceholderString`:
```go
// in english.go
DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?",
// at the call site
utils.ResolvePlaceholderString(
self.c.Tr.DeleteBranchTitle,
map[string]string{"selectedBranchName": branchName},
)
```
Named placeholders tell localizers what each value is (a bare `%s` says
nothing, and translators can't safely reorder positional verbs across
languages), and the map form extends cleanly when a string later needs more
than one placeholder. This holds for every user-facing string, including short
ones like disabled-action reasons and toasts.
## Code comments are for future readers, not development history
Comments in source code explain *why this code is shaped the way it is*. They
@ -283,7 +324,7 @@ So:
- For changes to `userConfig` fields specifically, don't edit
`docs-master/Config.md` by hand either — the relevant section is
auto-generated from the struct field doc comments. After editing the
struct, run `make generate` and include the regenerated
struct, run `just generate` and include the regenerated
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
*only* to `userConfig`, because those comments are fed through the doc

View file

@ -110,6 +110,21 @@ gui:
# is true.
expandedSidePanelWeight: 2
# The side panels, in the order they appear from top to bottom.
# Each entry is a list of one or more names that share a single panel as tabs
# (cycle through them with the next-tab/previous-tab keys).
# Omit a name to hide it; give a name its own one-element list to promote a tab
# to a top-level panel.
# Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches',
# 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and
# 'commits' must always be included; they can't be hidden.
sidePanels:
- [status]
- [files, worktrees, submodules]
- [branches, remotes, tags]
- [commits, reflog]
- [stash]
# Sometimes the main window is split in two (e.g. when the selected file has
# both staged and unstaged changes). This setting controls how the two sections
# are split.
@ -414,6 +429,11 @@ git:
# If true, periodically refresh files and submodules
autoRefresh: true
# If true, poll the repo periodically for external ref changes (commits, branch
# updates, checkouts made outside lazygit) and refresh when one is detected.
# Independent of autoRefresh, which only governs the files panel.
autoDetectExternalChanges: true
# If not "none", lazygit will automatically fast-forward local branches to match
# their upstream after fetching. Applies to branches that are not the currently
# checked out branch, and only to those that are strictly behind their upstream
@ -525,6 +545,11 @@ refresher:
# Auto-fetch can be disabled via option 'git.autoFetch'.
fetchInterval: 60
# Interval in seconds at which lazygit polls for external ref changes (commits,
# branch updates, checkouts made outside lazygit).
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
externalChangeCheckInterval: 2
# If true, show a confirmation popup before quitting Lazygit
confirmOnQuit: false
@ -693,6 +718,7 @@ keybinding:
increaseRenameSimilarityThreshold: )
decreaseRenameSimilarityThreshold: (
openDiffTool: <ctrl+t>
editConfig: <alt+shift+c>
status:
checkForUpdate: u
recentRepos: <enter>

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | Quit | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Edit config file | Open file in external editor. |
| `` z `` | Undo | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
@ -348,7 +349,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | Open config file | Open file in default application. |
| `` e `` | Edit config file | Open file in external editor. |
| `` u `` | Check for update | |
| `` <enter> `` | Switch to a recent repo | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | 終了 | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 空白表示の切り替え | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 |
| `` Z `` | やり直す | 最後のgitコマンドをやり直すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 |
@ -179,7 +180,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | 設定ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` u `` | 更新を確認 | |
| `` <enter> `` | 最近のリポジトリをチェックアウト | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | 종료 | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | 설정 파일 수정 | Open file in external editor. |
| `` z `` | 되돌리기 (reflog) (실험적) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | 다시 실행 (reflog) (실험적) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
@ -237,7 +238,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | 설정 파일 열기 | Open file in default application. |
| `` e `` | 설정 파일 수정 | Open file in external editor. |
| `` u `` | 업데이트 확인 | |
| `` <enter> `` | 최근에 사용한 저장소로 전환 | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | Quit | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Verander config bestand | Open file in external editor. |
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
@ -348,7 +349,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | Open config bestand | Open file in default application. |
| `` e `` | Verander config bestand | Open file in external editor. |
| `` u `` | Check voor updates | |
| `` <enter> `` | Wissel naar een recente repo | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | Wyjdź | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | Przełącz białe znaki | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. |
| `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. |
| `` Z `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. |
@ -327,7 +328,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | Otwórz plik konfiguracyjny | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. |
| `` u `` | Sprawdź aktualizacje | |
| `` <enter> `` | Przełącz na ostatnie repozytorium | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | Sair | |
| `` <ctrl+z> `` | Suspender a aplicação | |
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Editar arquivo de configuração | Abrir arquivo no editor externo. |
| `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. |
| `` Z `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. |
@ -357,7 +358,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | Abrir o ficheiro de config | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. |
| `` u `` | Verificar atualização | |
| `` <enter> `` | Mudar para um repositório recente | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | Выйти | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | Переключить отображение изменении пробелов в просмотрщике сравнении | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Редактировать файл конфигурации | Open file in external editor. |
| `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. |
| `` Z `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. |
@ -314,7 +315,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | Открыть файл конфигурации | Open file in default application. |
| `` e `` | Редактировать файл конфигурации | Open file in external editor. |
| `` u `` | Проверить обновления | |
| `` <enter> `` | Переключиться на последний репозиторий | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | 退出 | |
| `` <ctrl+z> `` | 挂起应用程序 | |
| `` <ctrl+w> `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。<br><br>默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 |
| `` <alt+shift+c> `` | 编辑配置文件 | 使用外部编辑器打开文件 |
| `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改只考虑提交。 |
| `` Z `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改只考虑提交。 |
@ -340,7 +341,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | 打开配置文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 |
| `` u `` | 检查更新 | |
| `` <enter> `` | 切换到最近的仓库 | |

View file

@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` q, <ctrl+c> `` | 結束 | |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
@ -369,7 +370,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` o `` | 開啟設定檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` u `` | 檢查更新 | |
| `` <enter> `` | 切換到最近使用的版本庫 | |

View file

@ -22,7 +22,12 @@ unit-test:
go test ./... -short
# Run both unit tests and integration tests.
test: unit-test e2e-all
[unix]
test: unit-test e2e
# On Windows, integration tests are not supported right now
[windows]
test: unit-test
# Generate all our auto-generated files (test list, cheatsheets, json schema, maybe other things in the future)
generate:
@ -34,18 +39,29 @@ format:
lint:
./scripts/golangci-lint-shim.sh run
# Run integration tests with a visible UI. Most useful for running a single test; for running all tests, use `e2e-all` instead.
e2e-test-command := "go test pkg/integration/clients/*.go"
# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI.
e2e *args:
{{ if args == "" { e2e-test-command } else { \
e2e-test-command + " -run 'TestIntegration/" + \
replace( \
replace_regex( \
replace_regex(args, '\S*pkg/integration/tests/', ''), \
'\.go( |$)', '${1}' \
), \
" ", "$' && " + e2e-test-command + " -run 'TestIntegration/" \
) + "$'" \
} }}
# Run a single integration test with a visible UI; most useful with --sandbox or --slow.
e2e-cli *args:
go run cmd/integration_test/main.go cli {{ args }}
# Open the TUI for running integration tests.
e2e-tui *args:
go run cmd/integration_test/main.go tui {{ args }}
# Run all integration tests headlessly (without a visible UI).
e2e-all:
go test pkg/integration/clients/*.go
# Run some tests on the current commit, similar to what CI does.
check:
./scripts/check_commit.sh

View file

@ -5,39 +5,16 @@ import (
"github.com/sirupsen/logrus"
)
// all we're doing here is wrapping the default command object builder with
// some git-specific stuff: e.g. adding a git-specific env var
type gitCmdObjBuilder struct {
innerBuilder *oscommands.CmdObjBuilder
}
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder {
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
// NewGitCmdObjBuilder returns a command object builder whose runner is wrapped
// with our git-specific runner (logging, credential handling, etc.).
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *oscommands.CmdObjBuilder {
// We decorate the runner rather than exposing the builder's runner field:
// that field stays unexported so there's a single API for running commands
// across the codebase.
return innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
log: log,
innerRunner: runner,
}
})
return &gitCmdObjBuilder{
innerBuilder: updatedBuilder,
}
}
var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0"
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) Quote(str string) string {
return self.innerBuilder.Quote(str)
}

View file

@ -168,6 +168,12 @@ func buildBranchCommands(deps commonDeps) *BranchCommands {
return NewBranchCommands(gitCommon)
}
func buildStatusCommands(deps commonDeps) *StatusCommands {
gitCommon := buildGitCommon(deps)
return NewStatusCommands(gitCommon)
}
func buildFlowCommands(deps commonDeps) *FlowCommands {
gitCommon := buildGitCommon(deps)

View file

@ -36,6 +36,11 @@ type GetStatusFileOptions struct {
// This is useful for users with bare repos for dotfiles who default to hiding untracked files,
// but want to occasionally see them to `git add` a new file.
ForceShowUntracked bool
// When true, this status is part of an unattended background refresh, so we
// pass --no-optional-locks to avoid index.lock contention with git commands
// the user runs in a terminal (at the cost of not persisting git's refreshed
// stat-cache).
Background bool
}
func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File {
@ -47,7 +52,7 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
}
untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg})
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg, Background: opts.Background})
if err != nil {
self.Log.Error(err)
}
@ -148,6 +153,7 @@ func (self *FileLoader) getFileDiffs() (map[string]FileDiff, error) {
type GitStatusOptions struct {
NoRenames bool
UntrackedFilesArg string
Background bool
}
type FileStatus struct {
@ -169,6 +175,7 @@ func (self *FileLoader) gitDiffNumStat() (string, error) {
func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) {
cmdArgs := NewGitCmd("status").
GlobalArgIf(opts.Background, "--no-optional-locks").
Arg(opts.UntrackedFilesArg).
Arg("--porcelain").
Arg("-z").

View file

@ -13,6 +13,7 @@ func TestFileGetStatusFiles(t *testing.T) {
type scenario struct {
testName string
similarityThreshold int
background bool
runner oscommands.ICmdObjRunner
showNumstatInFilesView bool
expectedFiles []*models.File
@ -26,6 +27,14 @@ func TestFileGetStatusFiles(t *testing.T) {
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
expectedFiles: []*models.File{},
},
{
testName: "Background refresh passes --no-optional-locks",
similarityThreshold: 50,
background: true,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"--no-optional-locks", "status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
expectedFiles: []*models.File{},
},
{
testName: "Several files found",
similarityThreshold: 50,
@ -246,7 +255,7 @@ func TestFileGetStatusFiles(t *testing.T) {
getFileType: func(string) string { return "file" },
}
assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{}))
assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{Background: s.background}))
})
}
}

View file

@ -38,6 +38,22 @@ func (self *GitCommandBuilder) ArgIfElse(condition bool, ifTrue string, ifFalse
return self.Arg(ifFalse)
}
// GlobalArg adds top-level options for git itself (e.g. --no-optional-locks).
// Unlike Arg, these are prepended before the command, where git expects them.
func (self *GitCommandBuilder) GlobalArg(args ...string) *GitCommandBuilder {
self.args = append(append([]string{}, args...), self.args...)
return self
}
func (self *GitCommandBuilder) GlobalArgIf(condition bool, args ...string) *GitCommandBuilder {
if condition {
self.GlobalArg(args...)
}
return self
}
func (self *GitCommandBuilder) Config(value string) *GitCommandBuilder {
// config settings come before the command
self.args = append([]string{"-c", value}, self.args...)

View file

@ -4,8 +4,10 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/spf13/afero"
)
type StatusCommands struct {
@ -82,6 +84,66 @@ func (self *StatusCommands) IsInRevert() (bool, error) {
return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD"))
}
// RefsSnapshot returns a string fingerprint of the current state of local
// branches and HEAD. Comparing two snapshots byte-for-byte tells us whether
// any local ref or HEAD has moved since the last snapshot.
func (self *StatusCommands) RefsSnapshot() (string, error) {
t := time.Now()
defer func() { self.Log.Infof("RefsSnapshot took %s", time.Since(t)) }()
refsArgs := NewGitCmd("for-each-ref").
Arg("--format=%(objectname) %(refname)").
Arg("refs/heads").
ToArgv()
refs, err := self.cmd.New(refsArgs).DontLog().RunWithOutput()
if err != nil {
return "", err
}
head, err := self.headSnapshot()
if err != nil {
return "", err
}
return refs + head, nil
}
// headSnapshot returns a fingerprint of HEAD that distinguishes "detached at
// commit X" from "on a branch that points at X". The commit hash alone can't
// tell those apart, which matters at the end of a rebase: HEAD reattaches to
// the branch without the hash changing, and we'd otherwise miss that refresh.
//
// We read .git/HEAD directly rather than shelling out: it's faster (no child
// process) and its content is exactly the symref-or-hash distinction we want
// ("ref: refs/heads/foo" when attached, the raw hash when detached). The
// reftable backend, however, doesn't keep a real .git/HEAD — it writes a fixed
// stub ("ref: refs/heads/.invalid") that never reflects the actual HEAD. When
// we see that stub (or the file is missing/unreadable) we fall back to
// porcelain commands, which are backend-agnostic.
func (self *StatusCommands) headSnapshot() (string, error) {
headPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "HEAD")
if content, err := afero.ReadFile(self.Fs, headPath); err == nil {
head := strings.TrimSpace(string(content))
if head != "" && head != "ref: refs/heads/.invalid" {
return head, nil
}
}
// symbolic-ref gives the branch when HEAD is attached and fails when it's
// detached, in which case rev-parse gives the commit HEAD points at.
symbolicRefArgs := NewGitCmd("symbolic-ref").Arg("HEAD").ToArgv()
if symref, err := self.cmd.New(symbolicRefArgs).DontLog().RunWithOutput(); err == nil {
return strings.TrimSpace(symref), nil
}
revParseArgs := NewGitCmd("rev-parse").Arg("HEAD").ToArgv()
head, err := self.cmd.New(revParseArgs).DontLog().RunWithOutput()
if err != nil {
return "", err
}
return strings.TrimSpace(head), nil
}
// Full ref (e.g. "refs/heads/mybranch") of the branch that is currently
// being rebased, or empty string when we're not in a rebase
func (self *StatusCommands) BranchBeingRebased() string {

View file

@ -0,0 +1,91 @@
package git_commands
import (
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
)
func TestStatusRefsSnapshot(t *testing.T) {
const forEachRefOutput = "aaaa refs/heads/main\nbbbb refs/heads/topic\n"
forEachRefArgs := []string{"for-each-ref", "--format=%(objectname) %(refname)", "refs/heads"}
scenarios := []struct {
testName string
headFile *string // nil means: don't create a .git/HEAD file (simulates it being unreadable).
runner *oscommands.FakeCmdObjRunner
expectedHead string
}{
{
// files backend, on a branch: read straight from .git/HEAD, no
// child process for HEAD.
testName: "attached, read from HEAD file",
headFile: lo.ToPtr("ref: refs/heads/main\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
expectedHead: "ref: refs/heads/main",
},
{
// files backend, detached: .git/HEAD holds the raw hash.
testName: "detached, read from HEAD file",
headFile: lo.ToPtr("aaaa\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
expectedHead: "aaaa",
},
{
// reftable backend (HEAD is a fixed stub), attached: fall back to
// symbolic-ref, which succeeds.
testName: "reftable stub, attached, fall back to symbolic-ref",
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
expectedHead: "refs/heads/main",
},
{
// reftable backend, detached: symbolic-ref fails, fall back to
// rev-parse.
testName: "reftable stub, detached, fall back to rev-parse",
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "", errors.New("fatal: ref HEAD is not a symbolic ref")).
ExpectGitArgs([]string{"rev-parse", "HEAD"}, "aaaa\n", nil),
expectedHead: "aaaa",
},
{
// HEAD file missing/unreadable: same fallback as reftable.
testName: "no HEAD file, fall back to symbolic-ref",
headFile: nil,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
expectedHead: "refs/heads/main",
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
fs := afero.NewMemMapFs()
if s.headFile != nil {
assert.NoError(t, afero.WriteFile(fs, "/repo/.git/HEAD", []byte(*s.headFile), 0o600))
}
instance := buildStatusCommands(commonDeps{
runner: s.runner,
fs: fs,
repoPaths: MockRepoPaths("/repo"),
})
snapshot, err := instance.RefsSnapshot()
assert.NoError(t, err)
assert.Equal(t, forEachRefOutput+s.expectedHead, snapshot)
s.runner.CheckForMissingCalls()
})
}
}

View file

@ -111,6 +111,72 @@ func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, er
}), nil
}
// GetConflictCommits returns the three gitlink commits of a conflicted submodule
// from the index: the merge base, our (current) commit, and their (incoming)
// commit. Any of them can be empty if that stage is absent (e.g. a submodule
// that was added on only one side). The path is relative to the repo root.
func (self *SubmoduleCommands) GetConflictCommits(path string) (base string, ours string, theirs string, err error) {
cmdArgs := NewGitCmd("ls-files").Arg("-u", "-z", "--", path).ToArgv()
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return "", "", "", err
}
// Each NUL-terminated entry looks like "<mode> <sha> <stage>\t<path>".
for _, entry := range strings.Split(output, "\x00") {
// fields are split on the tab and the spaces, so the leading three are
// always mode, sha, stage regardless of what the path contains.
fields := strings.Fields(entry)
if len(fields) < 3 {
continue
}
switch fields[2] {
case "1":
base = fields[1]
case "2":
ours = fields[1]
case "3":
theirs = fields[1]
}
}
return base, ours, theirs, nil
}
// GetCommitSummary returns "<short-sha> <subject>" for a commit inside the
// submodule at the given path, for display in the conflict menu.
func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string, error) {
cmdArgs := NewGitCmd("log").
Dir(path).
Arg("--format=%h %s", "--max-count=1", sha).
Config("log.showsignature=false").
ToArgv()
summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err
}
// CheckoutConflictCommit resolves a submodule conflict by checking the submodule
// out at the given commit. `git checkout --ours/--theirs` is a no-op on
// gitlinks, so we check out the chosen commit in the submodule itself; the
// caller then stages the submodule to record the resolution.
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
return self.cmd.New(cmdArgs).Run()
}
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
// that `side` has but `otherSide` does not (i.e. `otherSide..side`) — the commits
// unique to one side of a commit conflict, relative to their common ancestor. It
// is empty if `side` is an ancestor of `otherSide` (e.g. that side was rewound).
func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSide string) (string, error) {
cmdArgs := NewGitCmd("log").Dir(path).
Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
// because the intention here is to have no dirty worktree state

View file

@ -0,0 +1,92 @@
package git_commands
import (
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/stretchr/testify/assert"
)
func TestSubmoduleGetConflictCommits(t *testing.T) {
type scenario struct {
testName string
output string
expectedBase string
expectedOurs string
expectedTheirs string
}
scenarios := []scenario{
{
testName: "all three stages present (both modified)",
output: "160000 aaaaaaa 1\tmysub\x00160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00",
expectedBase: "aaaaaaa",
expectedOurs: "bbbbbbb",
expectedTheirs: "ccccccc",
},
{
testName: "only our and their stages (added on both sides)",
output: "160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00",
expectedBase: "",
expectedOurs: "bbbbbbb",
expectedTheirs: "ccccccc",
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, s.output, nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
base, ours, theirs, err := instance.GetConflictCommits("mysub")
assert.NoError(t, err)
assert.Equal(t, s.expectedBase, base)
assert.Equal(t, s.expectedOurs, ours)
assert.Equal(t, s.expectedTheirs, theirs)
runner.CheckForMissingCalls()
})
}
}
func TestSubmoduleGetConflictCommitsError(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, "", errors.New("error"))
instance := buildSubmoduleCommands(commonDeps{runner: runner})
_, _, _, err := instance.GetConflictCommits("mysub")
assert.Error(t, err)
runner.CheckForMissingCalls()
}
func TestSubmoduleGetCommitSummary(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-c", "log.showsignature=false", "-C", "mysub", "log", "--format=%h %s", "--max-count=1", "bbbbbbb"}, "bbbbbbb the subject\n", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
summary, err := instance.GetCommitSummary("mysub", "bbbbbbb")
assert.NoError(t, err)
assert.Equal(t, "bbbbbbb the subject", summary)
runner.CheckForMissingCalls()
}
func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "mysub", "checkout", "bbbbbbb"}, "", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb"))
runner.CheckForMissingCalls()
}
func TestSubmoduleConflictSideLog(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
output, err := instance.ConflictSideLog("mysub", "bbbbbbb", "ccccccc")
assert.NoError(t, err)
assert.Equal(t, "bbbbbbb left\n", output)
runner.CheckForMissingCalls()
}

View file

@ -160,3 +160,13 @@ func (c *Commit) IsTODO() bool {
func IsHeadCommit(commits []*Commit, index int) bool {
return !commits[index].IsTODO() && (index == 0 || commits[index-1].IsTODO())
}
func HeadCommitIdx(commits []*Commit) int {
for index, commit := range commits {
if !commit.IsTODO() {
return index
}
}
return -1
}

View file

@ -0,0 +1,81 @@
package models
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestHeadCommitIdx(t *testing.T) {
testCases := []struct {
name string
commits []*Commit
expected int
}{
{
name: "first commit without rebase todos",
commits: makeTestCommits("a", "b"),
expected: 0,
},
{
name: "first non-todo commit during an interactive rebase",
commits: []*Commit{
makeTestTodoCommit(todo.Pick),
makeTestTodoCommit(todo.Reword),
makeTestCommit("a"),
makeTestCommit("b"),
},
expected: 2,
},
{
name: "no commits",
commits: nil,
expected: -1,
},
{
name: "only rebase todos",
commits: []*Commit{
makeTestTodoCommit(todo.Pick),
makeTestTodoCommit(todo.Reword),
},
expected: -1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, HeadCommitIdx(testCase.commits))
})
}
}
func TestIsHeadCommit(t *testing.T) {
commits := []*Commit{
makeTestTodoCommit(todo.Pick),
makeTestCommit("a"),
makeTestCommit("b"),
}
assert.False(t, IsHeadCommit(commits, 0))
assert.True(t, IsHeadCommit(commits, 1))
assert.False(t, IsHeadCommit(commits, 2))
}
func makeTestCommits(hashes ...string) []*Commit {
commits := make([]*Commit, 0, len(hashes))
for _, hash := range hashes {
commits = append(commits, makeTestCommit(hash))
}
return commits
}
func makeTestCommit(hash string) *Commit {
return NewCommit(&utils.StringPool{}, NewCommitOpts{Hash: hash})
}
func makeTestTodoCommit(action todo.TodoCommand) *Commit {
return NewCommit(&utils.StringPool{}, NewCommitOpts{Action: action})
}

View file

@ -48,26 +48,34 @@ func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string
if len(shellFunctionsFile) > 0 {
commandStr = fmt.Sprintf("%ssource %s\n%s", self.platform.PrefixForShellFunctionsFile, shellFunctionsFile, commandStr)
}
quotedCommand := self.quotedCommandString(commandStr)
if self.platform.OS == "windows" {
return self.newWindowsShell(commandStr)
}
quotedCommand := self.Quote(commandStr)
cmdArgs := str.ToArgv(fmt.Sprintf("%s %s %s", self.platform.Shell, self.platform.ShellArg, quotedCommand))
return self.New(cmdArgs)
}
func (self *CmdObjBuilder) quotedCommandString(commandStr string) string {
// Windows does not seem to like quotes around the command
if self.platform.OS == "windows" {
return strings.NewReplacer(
"^", "^^",
"&", "^&",
"|", "^|",
"<", "^<",
">", "^>",
"%", "^%",
).Replace(commandStr)
}
// newWindowsShell wraps the command in `cmd.exe /s /c "<command>"`. The /s
// flag tells cmd to strip exactly the outermost pair of quotes and pass the
// rest through unchanged, which preserves any quoting the command itself
// contains (e.g. `"C:\Program Files\my-editor.exe" file.txt`). Without /s,
// cmd's default rules drop the wrong quotes once the command line contains
// more than two of them.
//
// We bypass Go's standard arg quoting via SysProcAttr.CmdLine: it follows the
// CommandLineToArgvW convention (`\"` for inner quotes), but cmd.exe doesn't.
func (self *CmdObjBuilder) newWindowsShell(commandStr string) *CmdObj {
args := []string{self.platform.Shell, "/s", self.platform.ShellArg, commandStr}
cmdObj := self.New(args)
return self.Quote(commandStr)
cmdLine := fmt.Sprintf(`%s /s %s "%s"`, self.platform.Shell, self.platform.ShellArg, commandStr)
setRawCmdLine(cmdObj.GetCmd(), cmdLine)
return cmdObj
}
func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdObjRunner) *CmdObjBuilder {
@ -80,21 +88,47 @@ func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdO
}
func (self *CmdObjBuilder) Quote(message string) string {
var quote string
if self.platform.OS == "windows" {
quote = `\"`
message = strings.NewReplacer(
`"`, `"'"'"`,
`\"`, `\\"`,
).Replace(message)
} else {
quote = `"`
message = strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
).Replace(message)
return quoteForWindows(message)
}
return quote + message + quote
message = strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
).Replace(message)
return `"` + message + `"`
}
// quoteForWindows encodes a value using the standard Windows command-line
// convention (the algorithm behind syscall.EscapeArg, reimplemented here so
// it's available on all platforms). The result is always wrapped in double
// quotes so cmd.exe and CommandLineToArgvW treat it as a single argument
// regardless of what shell metacharacters it contains.
func quoteForWindows(s string) string {
var b strings.Builder
b.WriteByte('"')
slashes := 0
for i := range len(s) {
c := s[i]
switch c {
case '\\':
slashes++
b.WriteByte(c)
case '"':
for ; slashes > 0; slashes-- {
b.WriteByte('\\')
}
b.WriteByte('\\')
b.WriteByte(c)
default:
slashes = 0
b.WriteByte(c)
}
}
for ; slashes > 0; slashes-- {
b.WriteByte('\\')
}
b.WriteByte('"')
return b.String()
}

View file

@ -105,12 +105,18 @@ func (self *cmdObjRunner) RunWithOutputAux(cmdObj *CmdObj) (string, error) {
}
t := time.Now()
output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput())
cmd := cmdObj.GetCmd()
output, err := sanitisedCommandOutput(cmd.CombinedOutput())
if err != nil {
self.log.WithField("command", cmdObj.ToString()).Error(output)
}
self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t))
wall := time.Since(t)
if ps := cmd.ProcessState; ps != nil {
self.log.Infof("%s (wall %s, cpu %s)", cmdObj.ToString(), wall, ps.UserTime()+ps.SystemTime())
} else {
self.log.Infof("%s (wall %s)", cmdObj.ToString(), wall)
}
return output, err
}

View file

@ -0,0 +1,389 @@
//go:build windows
package oscommands
import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
// These tests run only on Windows because they exercise real cmd.exe
// quote-parsing behaviour, which has only been a problem on Windows.
// makeWindowsShellBuilder returns a CmdObjBuilder configured for a real
// Windows cmd shell, bypassing the test "dummy" platform (which is darwin).
func makeWindowsShellBuilder() *CmdObjBuilder {
log := utils.NewDummyLog()
return &CmdObjBuilder{
runner: &cmdObjRunner{log: log, guiIO: NewNullGuiIO(log)},
platform: &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"},
}
}
// fakeEditorSrc is a minimal Go program that records the args it received,
// one per line, to marker.txt in its own directory. Using a real .exe (not a
// .bat) means args are parsed by Go's runtime via CommandLineToArgvW — the
// same algorithm used by ~all real Windows GUI editors. A .bat would parse
// args via cmd.exe's own rules, which can hide bugs that affect editors.
const fakeEditorSrc = `package main
import (
"os"
"path/filepath"
"strings"
)
func main() {
exe, err := os.Executable()
if err != nil {
os.Exit(2)
}
marker := filepath.Join(filepath.Dir(exe), "marker.txt")
body := strings.Join(os.Args[1:], "\n")
if err := os.WriteFile(marker, []byte(body), 0o644); err != nil {
os.Exit(3)
}
}
`
var (
fakeEditorOnce sync.Once
fakeEditorBytes []byte
fakeEditorErr error
)
// loadFakeEditorBytes builds the fake editor exactly once per test process
// and returns its bytes. Tests then drop a copy at a path containing spaces.
func loadFakeEditorBytes(t *testing.T) []byte {
t.Helper()
fakeEditorOnce.Do(func() {
buildDir, err := os.MkdirTemp("", "lazygit-fake-editor-build-*")
if err != nil {
fakeEditorErr = err
return
}
defer os.RemoveAll(buildDir)
srcPath := filepath.Join(buildDir, "main.go")
binPath := filepath.Join(buildDir, "fake-editor.exe")
if err := os.WriteFile(srcPath, []byte(fakeEditorSrc), 0o644); err != nil {
fakeEditorErr = err
return
}
cmd := exec.Command("go", "build", "-o", binPath, srcPath)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fakeEditorErr = err
return
}
fakeEditorBytes, fakeEditorErr = os.ReadFile(binPath)
})
if fakeEditorErr != nil {
t.Fatalf("failed to build fake editor helper: %v", fakeEditorErr)
}
return fakeEditorBytes
}
// placeFakeEditor builds the fake editor and places it at a path containing
// a space (mirroring `C:\Program Files\...`). The marker the editor writes
// lives next to the exe.
func placeFakeEditor(t *testing.T) (exe, markerFile string) {
t.Helper()
bin := loadFakeEditorBytes(t)
exeDir := filepath.Join(t.TempDir(), "Program Files", "FakeEditor")
if err := os.MkdirAll(exeDir, 0o755); err != nil {
t.Fatalf("mkdir exeDir: %v", err)
}
exe = filepath.Join(exeDir, "fake-editor.exe")
markerFile = filepath.Join(exeDir, "marker.txt")
if err := os.WriteFile(exe, bin, 0o755); err != nil {
t.Fatalf("write fake editor: %v", err)
}
return exe, markerFile
}
// placeTargetFile creates a file at <freshTempDir>/<dirName>/<basename> with
// a trivial body. Use a dirName containing a space (e.g. "my repo") to put
// the file at a path with spaces.
func placeTargetFile(t *testing.T, dirName, basename string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), dirName)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir target dir: %v", err)
}
target := filepath.Join(dir, basename)
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
return target
}
// setupFakeEditor is a convenience wrapper for the common case: editor at a
// spacey path AND target file at a spacey path — the conditions that
// trigger the cmd.exe quote-stripping bug.
func setupFakeEditor(t *testing.T) (fakeExe, targetFile, markerFile string) {
t.Helper()
fakeExe, markerFile = placeFakeEditor(t)
targetFile = placeTargetFile(t, "my repo", "file.txt")
return fakeExe, targetFile, markerFile
}
// resolveTemplate mirrors what pkg/commands/git_commands/file.go does: it
// substitutes {{filename}} with the Windows-quoted filename and {{line}}
// with a line number.
func resolveTemplate(builder *CmdObjBuilder, template, filename, line string) string {
out := strings.ReplaceAll(template, "{{filename}}", builder.Quote(filename))
out = strings.ReplaceAll(out, "{{line}}", line)
return out
}
// readMarkerArgs reads the args the fake editor recorded. Each arg is on its
// own line (so an arg that itself contains a space stays one element). An
// empty file means the editor ran with zero args.
func readMarkerArgs(t *testing.T, markerFile string) []string {
t.Helper()
data, err := os.ReadFile(markerFile)
if err != nil {
t.Fatalf("marker file was not written; the fake editor never ran: %v", err)
}
s := strings.TrimRight(string(data), "\r\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLineAndWait(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, targetFile, markerFile := setupFakeEditor(t)
template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}`
cmdStr := resolveTemplate(builder, template, targetFile, "42")
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
assert.Equal(t,
[]string{"-multiInst", "-nosession", "-noPlugin", "-n42", targetFile},
readMarkerArgs(t, markerFile),
)
}
func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLine(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, targetFile, markerFile := setupFakeEditor(t)
template := `"` + fakeExe + `" -n{{line}} {{filename}}`
cmdStr := resolveTemplate(builder, template, targetFile, "42")
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
assert.Equal(t,
[]string{"-n42", targetFile},
readMarkerArgs(t, markerFile),
)
}
func TestNewShell_QuotedExePath_FilenameWithSpaces_Edit(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, targetFile, markerFile := setupFakeEditor(t)
template := `"` + fakeExe + `" {{filename}}`
cmdStr := resolveTemplate(builder, template, targetFile, "")
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
assert.Equal(t,
[]string{targetFile},
readMarkerArgs(t, markerFile),
)
}
// Sanity check: for a filename WITHOUT spaces the same templates already work,
// because the resulting cmd.exe line has exactly two quote characters and
// cmd /c keeps them. This pins the difference down to filename quoting and
// guards against a regression where the no-spaces case starts failing too.
func TestNewShell_QuotedExePath_FilenameWithoutSpaces_StillWorks(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, markerFile := placeFakeEditor(t)
plainTarget := placeTargetFile(t, "repo", "plain.txt") // no-space dir
template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}`
cmdStr := resolveTemplate(builder, template, plainTarget, "42")
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
assert.Equal(t,
[]string{"-multiInst", "-nosession", "-noPlugin", "-n42", plainTarget},
readMarkerArgs(t, markerFile),
)
}
// TestNewShell_VarietyOfEditorTemplates exercises NewShell with a range of
// realistic editor templates, all with the trigger conditions of the bug
// (quoted exe at a spacey path + filename at a spacey path). Each subtest
// asserts the editor receives the exact args lazygit intended.
//
// Args in `wantArgs` may use the literal "<file>" placeholder; it gets
// substituted with the resolved target file path before comparison.
func TestNewShell_VarietyOfEditorTemplates(t *testing.T) {
const filePlaceholder = "<file>"
cases := []struct {
name string
template string // <exe> stands for the fake editor's full path
line string
wantArgs []string
}{
{
name: "vim/nvim style: +line filename",
template: `"<exe>" +{{line}} {{filename}}`,
line: "42",
wantArgs: []string{"+42", filePlaceholder},
},
{
name: "emacs-like with explicit +N",
template: `"<exe>" +{{line}} -nw {{filename}}`,
line: "7",
wantArgs: []string{"+7", "-nw", filePlaceholder},
},
{
name: "long flag with =value",
template: `"<exe>" --line={{line}} --tab-size=4 {{filename}}`,
line: "42",
wantArgs: []string{"--line=42", "--tab-size=4", filePlaceholder},
},
{
name: "many short and long flags before filename",
template: `"<exe>" -a -b -c --foo --bar -n{{line}} {{filename}}`,
line: "42",
wantArgs: []string{"-a", "-b", "-c", "--foo", "--bar", "-n42", filePlaceholder},
},
{
name: "flag after filename",
template: `"<exe>" {{filename}} --readonly`,
line: "",
wantArgs: []string{filePlaceholder, "--readonly"},
},
{
name: "single short flag attached to value",
template: `"<exe>" -n{{line}} {{filename}}`,
line: "1",
wantArgs: []string{"-n1", filePlaceholder},
},
{
name: "no flags, just filename",
template: `"<exe>" {{filename}}`,
line: "",
wantArgs: []string{filePlaceholder},
},
{
name: "flag with separate value (space-separated)",
template: `"<exe>" --goto {{line}} {{filename}}`,
line: "42",
wantArgs: []string{"--goto", "42", filePlaceholder},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, targetFile, markerFile := setupFakeEditor(t)
template := strings.ReplaceAll(tc.template, "<exe>", fakeExe)
cmdStr := resolveTemplate(builder, template, targetFile, tc.line)
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
want := make([]string, len(tc.wantArgs))
for i, a := range tc.wantArgs {
want[i] = strings.ReplaceAll(a, filePlaceholder, targetFile)
}
assert.Equal(t, want, readMarkerArgs(t, markerFile))
})
}
}
// TestNewShell_FilenameSpecialCharacters varies the basename of the target
// file across characters that are legal in Windows filenames but might
// interact badly with cmd.exe / Quote(): parentheses, brackets, single
// quote, comma, semicolon, equals, etc. The exe is at a spacey path and
// the target dir has spaces, so the bug-trigger conditions are still met.
func TestNewShell_FilenameSpecialCharacters(t *testing.T) {
cases := []struct {
name string
basename string
}{
{"parens", "file (1).txt"},
{"brackets", "file[v2].txt"},
{"single quote", "it's a file.txt"},
{"comma", "a,b,c.txt"},
{"semicolon", "a;b.txt"},
{"equals", "key=value.txt"},
{"plus", "a+b.txt"},
{"hash", "issue#42.txt"},
{"at sign", "user@host.txt"},
{"tilde", "~backup.txt"},
{"dot leading", ".gitignore.txt"},
{"multiple dots", "v1.2.3.txt"},
{"dash leading", "-flag-looking.txt"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
builder := makeWindowsShellBuilder()
fakeExe, markerFile := placeFakeEditor(t)
targetFile := placeTargetFile(t, "my repo", tc.basename)
template := `"` + fakeExe + `" -n{{line}} {{filename}}`
cmdStr := resolveTemplate(builder, template, targetFile, "42")
out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
assert.Equal(t,
[]string{"-n42", targetFile},
readMarkerArgs(t, markerFile),
)
})
}
}
// Command chaining with && must work: cmd /s /c runs the assembled line verbatim,
// so cmd treats && as a separator and runs both commands. The two echoes
// therefore produce two separate output lines.
func TestNewShell_CommandChaining(t *testing.T) {
builder := makeWindowsShellBuilder()
out, err := builder.NewShell("echo first&&echo second", "").GetCmd().CombinedOutput()
if err != nil {
t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out))
}
normalized := strings.ReplaceAll(string(out), "\r\n", "\n")
lines := strings.Split(strings.TrimSpace(normalized), "\n")
assert.Equal(t, []string{"first", "second"}, lines)
}

View file

@ -40,6 +40,11 @@ func (c *OSCommand) UpdateWindowTitle() error {
return nil
}
// setRawCmdLine is the non-Windows no-op counterpart of the Windows shim
// (see the comment there). NewShell's shell-building logic is portable, so
// this call is reached on every host; only the Windows build does anything.
func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {}
func TerminateProcessGracefully(cmd *exec.Cmd) error {
if cmd.Process == nil {
return nil

View file

@ -75,11 +75,26 @@ func TestOSCommandQuoteWindows(t *testing.T) {
actual := osCommand.Quote(`hello "test" 'test2'`)
expected := `\"hello "'"'"test"'"'" 'test2'\"`
expected := `"hello \"test\" 'test2'"`
assert.EqualValues(t, expected, actual)
}
// On Windows, NewShell must hand the command to cmd.exe verbatim.
func TestNewShellWindowsPassesMetacharactersVerbatim(t *testing.T) {
osCommand := NewDummyOSCommand()
platform := &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"}
osCommand.Platform = platform
osCommand.Cmd.platform = platform
command := `echo a && echo b | sort > out.txt < in.txt %PATH%`
assert.Equal(t,
[]string{"cmd", "/s", "/c", command},
osCommand.Cmd.NewShell(command, "").Args(),
)
}
func TestOSCommandFileType(t *testing.T) {
type scenario struct {
path string

View file

@ -5,8 +5,25 @@ import (
"os"
"os/exec"
"path/filepath"
"syscall"
)
// setRawCmdLine hands cmd.exe the exact command line we built, bypassing
// os/exec's default composition (which quotes args with the
// CommandLineToArgvW `\"` convention that cmd.exe doesn't understand).
//
// The shell-building logic in NewShell is portable and dispatches on
// platform.OS, which keeps it (and its quoting) unit-testable on any host.
// Assigning SysProcAttr.CmdLine is the only step that needs a Windows-only
// field, so it's the single piece split out behind a build tag; every other
// platform gets the no-op in os_default_platform.go.
func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CmdLine = cmdLine
}
func GetPlatform() *Platform {
return &Platform{
OS: "windows",

View file

@ -20,7 +20,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
{
filename: "test",
runner: NewFakeRunner(t).
ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", errors.New("error")),
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", errors.New("error")),
test: func(err error) {
assert.Error(t, err)
},
@ -28,7 +28,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
{
filename: "test",
runner: NewFakeRunner(t).
ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", nil),
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", nil),
test: func(err error) {
assert.NoError(t, err)
},
@ -36,7 +36,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
{
filename: "filename with spaces",
runner: NewFakeRunner(t).
ExpectArgs([]string{"cmd", "/c", "start", "", "filename with spaces"}, "", nil),
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "filename with spaces"`}, "", nil),
test: func(err error) {
assert.NoError(t, err)
},
@ -44,7 +44,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
{
filename: "let's_test_with_single_quote",
runner: NewFakeRunner(t).
ExpectArgs([]string{"cmd", "/c", "start", "", "let's_test_with_single_quote"}, "", nil),
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "let's_test_with_single_quote"`}, "", nil),
test: func(err error) {
assert.NoError(t, err)
},
@ -52,7 +52,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) {
{
filename: "$USER.txt",
runner: NewFakeRunner(t).
ExpectArgs([]string{"cmd", "/c", "start", "", "$USER.txt"}, "", nil),
ExpectArgs([]string{"cmd", "/s", "/c", `start "" "$USER.txt"`}, "", nil),
test: func(err error) {
assert.NoError(t, err)
},

View file

@ -654,6 +654,12 @@ git:
# If true, periodically refresh files and submodules
autoRefresh: true
# If true, poll the repo periodically for external ref changes (commits,
# branch updates, checkouts made outside lazygit) and refresh when one
# is detected. Independent of autoRefresh, which only governs the files
# panel.
autoDetectExternalChanges: true
# If true, pass the --all arg to git fetch
fetchAll: true
@ -723,6 +729,11 @@ refresher:
# Auto-fetch can be disabled via option 'git.autoFetch'.
fetchInterval: 60
# Interval in seconds at which lazygit polls for external ref changes
# (commits, branch updates, checkouts made outside lazygit).
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
externalChangeCheckInterval: 2
# If true, show a confirmation popup before quitting Lazygit
confirmOnQuit: false

54
pkg/config/side_panel.go Normal file
View file

@ -0,0 +1,54 @@
package config
import (
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
// SidePanel is one entry in gui.sidePanels: a side panel made up of one or more
// tabs, written in YAML as a list of tab names (e.g. [files, worktrees]).
type SidePanel []string
// ValidSidePanelTabs lists every name that may appear in gui.sidePanels. Each
// names a list that can stand alone as a panel or be grouped with others as the
// tabs of one panel. The resolver in the gui package must handle every entry
// here; a test enforces that the two stay in sync.
var ValidSidePanelTabs = []string{
"status",
"files",
"worktrees",
"submodules",
"branches",
"remotes",
"tags",
"commits",
"reflog",
"stash",
}
func (p SidePanel) MarshalYAML() (any, error) {
// Render in flow style (`[a, b]`) rather than the default block style, which
// is more compact and reads better in the generated docs.
node := &yaml.Node{
Kind: yaml.SequenceNode,
Style: yaml.FlowStyle,
}
for _, s := range p {
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: s,
})
}
return node, nil
}
// JSONSchema describes a side panel as a list of tab names, restricted to the
// known names.
func (SidePanel) JSONSchema() *jsonschema.Schema {
names := lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name })
return &jsonschema.Schema{
Type: "array",
Items: &jsonschema.Schema{Type: "string", Enum: names},
}
}

View file

@ -44,10 +44,13 @@ type UserConfig struct {
type RefresherConfig struct {
// File/submodule refresh interval in seconds.
// Auto-refresh can be disabled via option 'git.autoRefresh'.
RefreshInterval int `yaml:"refreshInterval" jsonschema:"minimum=0"`
RefreshInterval int `yaml:"refreshInterval" jsonschema:"exclusiveMinimum=0"`
// Re-fetch interval in seconds.
// Auto-fetch can be disabled via option 'git.autoFetch'.
FetchInterval int `yaml:"fetchInterval" jsonschema:"minimum=0"`
FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"`
// Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).
// Detection can be disabled via option 'git.autoDetectExternalChanges'.
ExternalChangeCheckInterval int `yaml:"externalChangeCheckInterval" jsonschema:"exclusiveMinimum=0"`
}
func (c *RefresherConfig) RefreshIntervalDuration() time.Duration {
@ -58,6 +61,10 @@ func (c *RefresherConfig) FetchIntervalDuration() time.Duration {
return time.Second * time.Duration(c.FetchInterval)
}
func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
return time.Second * time.Duration(c.ExternalChangeCheckInterval)
}
type GuiConfig struct {
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
@ -102,6 +109,11 @@ type GuiConfig struct {
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
// The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.
ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"`
// The side panels, in the order they appear from top to bottom.
// Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).
// Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.
// Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.
SidePanels []SidePanel `yaml:"sidePanels"`
// Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
// Options are:
// - 'horizontal': split the window horizontally
@ -296,6 +308,8 @@ type GitConfig struct {
AutoFetch bool `yaml:"autoFetch"`
// If true, periodically refresh files and submodules
AutoRefresh bool `yaml:"autoRefresh"`
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
@ -528,6 +542,7 @@ type KeybindingUniversalConfig struct {
IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"`
DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"`
OpenDiffTool Keybinding `yaml:"openDiffTool"`
EditConfig Keybinding `yaml:"editConfig"`
}
type KeybindingStatusConfig struct {
@ -844,6 +859,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
SidePanelWidth: 0.3333,
ExpandFocusedSidePanel: false,
ExpandedSidePanelWeight: 2,
SidePanels: []SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
{"stash"},
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInStagingView: true,
@ -927,6 +949,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
MainBranches: []string{"master", "main"},
AutoFetch: true,
AutoRefresh: true,
AutoDetectExternalChanges: true,
AutoForwardBranches: "onlyMainBranches",
FetchAll: true,
AutoStageResolvedConflicts: true,
@ -942,8 +965,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
TruncateCopiedCommitHashesTo: 12,
},
Refresher: RefresherConfig{
RefreshInterval: 10,
FetchInterval: 60,
RefreshInterval: 10,
FetchInterval: 60,
ExternalChangeCheckInterval: 2,
},
Update: UpdateConfig{
Method: "prompt",
@ -1040,6 +1064,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
IncreaseRenameSimilarityThreshold: Keybinding{")"},
DecreaseRenameSimilarityThreshold: Keybinding{"("},
OpenDiffTool: Keybinding{"<ctrl+t>"},
EditConfig: Keybinding{"<alt+shift+c>"},
},
Status: KeybindingStatusConfig{
CheckForUpdate: Keybinding{"u"},

View file

@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error {
if err := validateSpinner(config.Gui.Spinner); err != nil {
return err
}
if err := validateSidePanels(config.Gui.SidePanels); err != nil {
return err
}
return nil
}
func validateSidePanels(panels []SidePanel) error {
seen := map[string]bool{}
total := 0
for _, panel := range panels {
if len(panel) == 0 {
return errors.New("gui.sidePanels: a side panel must have at least one tab.")
}
for _, name := range panel {
if !slices.Contains(ValidSidePanelTabs, name) {
return fmt.Errorf("gui.sidePanels: unknown side panel '%s'. Allowed values: %s",
name, strings.Join(ValidSidePanelTabs, ", "))
}
if seen[name] {
return fmt.Errorf("gui.sidePanels: '%s' is listed more than once; each side panel may appear only once.", name)
}
seen[name] = true
total++
}
}
if total == 0 {
return errors.New("gui.sidePanels must not be empty.")
}
// A lot of code focuses these panels directly (e.g. after resolving a
// conflict or popping a stash), so they must always be present; otherwise
// that code would focus a hidden panel.
for _, required := range []string{"files", "branches", "commits"} {
if !seen[required] {
return fmt.Errorf("gui.sidePanels: '%s' must be included; it can't be hidden.", required)
}
}
return nil
}
@ -141,16 +177,7 @@ func validateKeybindingsRecurse(path string, node any) error {
}
func validateKeybindings(keybindingConfig KeybindingConfig) error {
if err := validateKeybindingsRecurse("", keybindingConfig); err != nil {
return err
}
if len(keybindingConfig.Universal.JumpToBlock) != 5 {
return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.",
len(keybindingConfig.Universal.JumpToBlock))
}
return nil
return validateKeybindingsRecurse("", keybindingConfig)
}
func validateCustomCommandKey(key Keybinding) error {

View file

@ -134,11 +134,12 @@ func TestUserConfigValidate_enums(t *testing.T) {
})
},
testCases: []testCase{
{value: "", valid: false},
{value: "1,2,3", valid: false},
// The number of entries no longer has to match the number of side
// panels, so only the validity of the individual keys matters.
{value: "1,2,3", valid: true},
{value: "1,2,3,4,5", valid: true},
{value: "1,2,3,4,5,6", valid: true},
{value: "1,2,3,4,invalid", valid: false},
{value: "1,2,3,4,5,6", valid: false},
},
},
{
@ -324,6 +325,42 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) {
}
}
func TestUserConfigValidate_sidePanels(t *testing.T) {
scenarios := []struct {
name string
panels []SidePanel
valid bool
}{
{name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true},
{name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true},
{name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true},
{name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "empty", panels: []SidePanel{}, valid: false},
{name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false},
{name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false},
{name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false},
{name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false},
{name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false},
{name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false},
{name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.SidePanels = s.panels
err := config.Validate()
if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
func TestUserConfigValidate_pagers(t *testing.T) {
scenarios := []struct {
name string

View file

@ -103,6 +103,7 @@ type replayedEvents struct {
Keys chan *TcellKeyEventWrapper
Resizes chan *TcellResizeEventWrapper
MouseEvents chan *TcellMouseEventWrapper
FocusEvents chan *TcellFocusEventWrapper
}
type RecordingConfig struct {
@ -245,6 +246,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
MouseEvents: make(chan *TcellMouseEventWrapper),
FocusEvents: make(chan *TcellFocusEventWrapper),
}
}
@ -1345,7 +1347,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
visibleLineWidth := 0
for _, c := range v.lines[newY] {
for _, c := range v.lines[newY].cells {
visibleLineWidth += c.width
}
if visibleLineWidth < newX {

View file

@ -266,6 +266,22 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventResize(wrapper.Width, wrapper.Height)
}
type TcellFocusEventWrapper struct {
Timestamp int64
Focused bool
}
func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
return &TcellFocusEventWrapper{
Timestamp: timestamp,
Focused: event.Focused,
}
}
func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventFocus(wrapper.Focused)
}
// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
@ -277,6 +293,8 @@ func (g *Gui) pollEvent() GocuiEvent {
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.MouseEvents:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.FocusEvents:
tev = (ev).toTcellEvent()
}
} else {
tev = <-Screen.EventQ()

View file

@ -28,12 +28,12 @@ const (
// position.
type View struct {
name string
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
wx, wy int // Write() offsets
lines [][]cell // All the data
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
wx, wy int // Write() offsets
lines []lineType // All the data
outMode OutputMode
// The y position of the first line of a range selection.
// This is not relative to the view's origin: it is relative to the first line
@ -444,6 +444,28 @@ type SearchPosition struct {
type viewLine struct {
linesX, linesY int // coordinates relative to v.lines
line []cell
// Colors used to extend the bg past this wrapped segment's content.
// Derived at wrap time from the source line — see refreshViewLinesIfNeeded
// for the per-segment rule.
trailingFillAttributes *trailingFillAttributes
}
// lineType is one of v.lines: the cells of a source line, plus optional
// trailingFillAttributes recording the colors used to extend the bg
// past the line's content when the writer emitted '\x1b[K'.
type lineType struct {
cells cells
trailingFillAttributes *trailingFillAttributes
}
// trailingFillAttributes describes the fg/bg colors that draw() should
// use for cells past the end of a wrapped segment's content. On a source
// line this records what the writer asked for via '\x1b[K' (and so opts
// the line in to trailing fill at all); the per-segment values on each
// viewLine are derived from it at wrap time.
type trailingFillAttributes struct {
fg, bg Attribute
}
type cell struct {
@ -453,7 +475,7 @@ type cell struct {
hyperlink string
}
type lineType []cell
type cells []cell
func characterEquals(chr []byte, b byte) bool {
return len(chr) == 1 && chr[0] == b
@ -464,7 +486,7 @@ func isCRLF(chr []byte) bool {
}
// String returns a string from a given cell slice.
func (l lineType) String() string {
func (l cells) String() string {
var str strings.Builder
for _, c := range l {
str.WriteString(c.chr)
@ -738,20 +760,20 @@ func (v *View) makeWriteable(x, y int) {
}
v.lines = v.lines[:newLen]
} else {
v.lines = append(v.lines, nil)
v.lines = append(v.lines, lineType{})
}
}
// cell `x` need not be index-able (that's why `<`)
// append should be used by `lines[y]` user if he wants to write beyond `x`
for len(v.lines[y]) < x {
if cap(v.lines[y]) > len(v.lines[y]) {
newLen := cap(v.lines[y])
for len(v.lines[y].cells) < x {
if cap(v.lines[y].cells) > len(v.lines[y].cells) {
newLen := cap(v.lines[y].cells)
if newLen > x {
newLen = x
}
v.lines[y] = v.lines[y][:newLen]
v.lines[y].cells = v.lines[y].cells[:newLen]
} else {
v.lines[y] = append(v.lines[y], cell{})
v.lines[y].cells = append(v.lines[y].cells, cell{})
}
}
}
@ -761,7 +783,7 @@ func (v *View) makeWriteable(x, y int) {
func (v *View) writeCells(cells []cell) {
var newLen int
// use maximum len available
line := v.lines[v.wy][:cap(v.lines[v.wy])]
line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)]
maxCopy := len(line) - v.wx
if maxCopy < len(cells) {
copy(line[v.wx:], cells[:maxCopy])
@ -770,11 +792,11 @@ func (v *View) writeCells(cells []cell) {
} else { // maxCopy >= len(cells)
copy(line[v.wx:], cells)
newLen = v.wx + len(cells)
if newLen < len(v.lines[v.wy]) {
newLen = len(v.lines[v.wy])
if newLen < len(v.lines[v.wy].cells) {
newLen = len(v.lines[v.wy].cells)
}
}
v.lines[v.wy] = line[:newLen]
v.lines[v.wy].cells = line[:newLen]
v.wx += len(cells)
}
@ -800,21 +822,13 @@ func (v *View) write(p []byte) {
finishLine := func() {
v.autoRenderHyperlinksInCurrentLine()
if v.wx >= len(v.lines[v.wy]) {
v.writeCells([]cell{{
chr: "",
width: 0,
fgColor: 0,
bgColor: 0,
}})
}
}
advanceToNextLine := func() {
v.wx = 0
v.wy++
if v.wy >= len(v.lines) {
v.lines = append(v.lines, nil)
v.lines = append(v.lines, lineType{})
}
}
@ -851,7 +865,7 @@ func (v *View) write(p []byte) {
}
v.writeCells(cells)
if truncateLine {
v.lines[v.wy] = v.lines[v.wy][:v.wx]
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
}
}
}
@ -910,7 +924,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
return
}
line := v.lines[v.wy]
line := v.lines[v.wy].cells
start := 0
for {
linkStart := findLinkStart(line[start:])
@ -927,7 +941,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
link.WriteString(line[linkEnd].chr)
}
for i := linkStart; i < linkEnd; i++ {
v.lines[v.wy][i].hyperlink = link.String()
v.lines[v.wy].cells[i].hyperlink = link.String()
}
start = linkEnd
}
@ -955,16 +969,19 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
} else {
repeatCount := 1
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
// fill rest of line
// Discard any old content past the cursor and record the
// fill colors so draw() paints the trailing area with them.
// This extends the bg to the right edge in both the
// content-fits and content-wraps cases — for the latter,
// the metadata is what reaches every wrapped segment past
// the last word.
v.ei.instructionRead()
cx := 0
for _, cell := range v.lines[v.wy][0:v.wx] {
cx += cell.width
}
repeatCount = v.InnerWidth() - cx
ch = []byte{' '}
width = 1
truncateLine = true
v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{
fg: v.ei.curFgColor,
bg: v.ei.curBgColor,
}
return truncateLine, []cell{}
} else if isEscape {
// do not output anything
return truncateLine, nil
@ -1010,8 +1027,8 @@ func (v *View) Read(p []byte) (n int, err error) {
v.readBuffer = nil
}
for v.ry < len(v.lines) {
for v.rx < len(v.lines[v.ry]) {
s := v.lines[v.ry][v.rx].chr
for v.rx < len(v.lines[v.ry].cells) {
s := v.lines[v.ry].cells[v.rx].chr
count := len(s)
copy(p[offset:], s)
v.rx++
@ -1175,9 +1192,9 @@ func (v *View) updateSearchPositions() {
}
// If a view line exists for this line index:
if v.lines[result.Y] != nil {
if v.lines[result.Y].cells != nil {
// search this view line for the search string
positions := searchPositionsForLine(v.lines[result.Y], result.Y)
positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y)
if len(positions) > 0 {
// If we found any occurrences, add them
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
@ -1248,13 +1265,21 @@ func (v *View) draw() {
}
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
var prevFgColor Attribute
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
}
// Decide the colors used for cells past the end of vline.line:
// the source line's trailingFillAttributes (set by '\x1b[K') if
// any, otherwise plain defaults.
trailingCell := emptyCell
if attrs := vline.trailingFillAttributes; attrs != nil {
trailingCell.fgColor = attrs.fg
trailingCell.bgColor = attrs.bg
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
@ -1277,14 +1302,9 @@ func (v *View) draw() {
// if we're out of cells to write, we'll just print empty cells.
if cellIdx > len(vline.line)-1 {
c = emptyCell
c.fgColor = prevFgColor
c = trailingCell
} else {
c = vline.line[cellIdx]
// capturing previous foreground colour so that if we're using the reverse
// attribute we honour the final character's colour and don't awkwardly switch
// to a new background colour for the remainder of the line
prevFgColor = c.fgColor
}
fgColor := c.fgColor
@ -1318,9 +1338,27 @@ func (v *View) refreshViewLinesIfNeeded() {
wrap = maxX
}
ls := lineWrap(line, wrap)
ls := lineWrap(line.cells, wrap)
for j := range ls {
vline := viewLine{linesX: j, linesY: i, line: ls[j]}
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
@ -1411,9 +1449,7 @@ func (v *View) BufferLines() []string {
lines := make([]string, len(v.lines))
for i, l := range v.lines {
str := lineType(l).String()
str = strings.ReplaceAll(str, "\x00", "")
lines[i] = str
lines[i] = l.cells.String()
}
return lines
}
@ -1434,9 +1470,7 @@ func (v *View) ViewBufferLines() []string {
lines := make([]string, len(v.viewLines))
for i, l := range v.viewLines {
str := lineType(l.line).String()
str = strings.ReplaceAll(str, "\x00", "")
lines[i] = str
lines[i] = cells(l.line).String()
}
return lines
}
@ -1458,12 +1492,12 @@ func (v *View) ViewLinesHeight() int {
// ViewBuffer returns a string with the contents of the view's buffer that is
// shown to the user.
func (v *View) ViewBuffer() string {
lines := make([][]cell, len(v.viewLines))
strs := make([]string, len(v.viewLines))
for i := range v.viewLines {
lines[i] = v.viewLines[i].line
strs[i] = cells(v.viewLines[i].line).String()
}
return linesToString(lines)
return strings.Join(strs, "\n")
}
// Line returns a string with the line of the view's internal buffer
@ -1478,7 +1512,7 @@ func (v *View) Line(y int) (string, bool) {
return "", false
}
return lineType(v.lines[y]).String(), true
return v.lines[y].cells.String(), true
}
// Word returns a string with the word of the view's internal buffer
@ -1489,11 +1523,11 @@ func (v *View) Word(x, y int) (string, bool) {
return "", false
}
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y]) {
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) {
return "", false
}
str := lineType(v.lines[y]).String()
str := v.lines[y].cells.String()
nl := strings.LastIndexFunc(str[:x], indexFunc)
if nl == -1 {
@ -1523,9 +1557,8 @@ func (v *View) SetHighlight(y int, on bool) {
return
}
line := v.lines[y]
cells := make([]cell, 0)
for _, c := range line {
cells := make([]cell, 0, len(v.lines[y].cells))
for _, c := range v.lines[y].cells {
if on {
c.bgColor = v.SelBgColor
c.fgColor = v.SelFgColor
@ -1536,7 +1569,7 @@ func (v *View) SetHighlight(y int, on bool) {
cells = append(cells, c)
}
v.tainted = true
v.lines[y] = cells
v.lines[y].cells = cells
v.clearHover()
}
@ -1602,17 +1635,10 @@ func lineWrap(line []cell, columns int) [][]cell {
return lines
}
func linesToString(lines [][]cell) string {
func linesToString(lines []lineType) string {
str := make([]string, len(lines))
for i := range lines {
rns := make([]rune, 0, len(lines[i]))
line := lineType(lines[i]).String()
for _, c := range line {
if c != '\x00' {
rns = append(rns, c)
}
}
str[i] = string(rns)
str[i] = lines[i].cells.String()
}
return strings.Join(str, "\n")
@ -1682,9 +1708,7 @@ func (v *View) SelectedLines() []string {
}
func (v *View) lineContentAtIdx(idx int) string {
line := v.lines[idx]
str := lineType(line).String()
return strings.ReplaceAll(str, "\x00", "")
return v.lines[idx].cells.String()
}
func (v *View) SelectedPoint() (int, int) {
@ -1788,11 +1812,11 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
v.overwriteLines(y, content)
for i := range y {
v.lines[i] = nil
v.lines[i] = lineType{}
}
for i := v.wy + 1; i < len(v.lines); i += 1 {
v.lines[i] = nil
v.lines[i] = lineType{}
}
}
@ -1935,7 +1959,7 @@ func (v *View) scrollMargin() int {
// foreground color
func (v *View) ContainsColoredText(fgColor string, text string) bool {
for _, line := range v.lines {
if containsColoredTextInLine(fgColor, text, line) {
if containsColoredTextInLine(fgColor, text, line.cells) {
return true
}
}

View file

@ -8,10 +8,28 @@ import (
"strings"
"testing"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg"
"github.com/stretchr/testify/assert"
)
// WithSimulationScreen swaps the package-level Screen for a tcell
// terminfo-backed mock terminal so tests can call view.draw() and
// inspect rendered cells via Screen.Get(). The previous Screen is
// restored on test cleanup.
func WithSimulationScreen(t *testing.T, width, height int) {
t.Helper()
saved := Screen
if err := (&Gui{}).tcellInitSimulation(width, height); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
Screen.Fini()
Screen = saved
})
}
func TestWriteString(t *testing.T) {
tests := []struct {
existingLines []string
@ -26,17 +44,17 @@ func TestWriteString(t *testing.T) {
{
[]string{},
[]string{"1\n"},
[][]string{{"1", ""}},
[][]string{{"1"}},
},
{
[]string{},
[]string{"1\n", "2\n"},
[][]string{{"1", ""}, {"2", ""}},
[][]string{{"1"}, {"2"}},
},
{
[]string{"a"},
[]string{"1\n"},
[][]string{{"1", ""}},
[][]string{{"1"}},
},
{
[]string{"a\x00"},
@ -56,12 +74,12 @@ func TestWriteString(t *testing.T) {
{
[]string{},
[]string{"1\r"},
[][]string{{"1", ""}},
[][]string{{"1"}},
},
{
[]string{"a"},
[]string{"1\r"},
[][]string{{"1", ""}},
[][]string{{"1"}},
},
{
[]string{"a\x00"},
@ -83,14 +101,14 @@ func TestWriteString(t *testing.T) {
for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines {
v.lines = append(v.lines, stringToCells(l))
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
}
for _, s := range test.stringsToWrite {
v.writeString(s)
}
var resultingLines [][]string
for _, l := range v.lines {
resultingLines = append(resultingLines, cellsToStrings(l))
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
assert.Equal(t, test.expectedLines, resultingLines)
}
@ -126,19 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt")
// No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.lines[0][0].hyperlink)
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet)
v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink)
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
assert.Equal(t, "https://exa", v.lines[0][0].hyperlink)
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link
v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink)
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
}
func TestContainsColoredText(t *testing.T) {
@ -211,7 +229,11 @@ func TestContainsColoredText(t *testing.T) {
}
for i, test := range tests {
v := &View{lines: test.lines}
lines := make([]lineType, len(test.lines))
for j, cells := range test.lines {
lines[j] = lineType{cells: cells}
}
v := &View{lines: lines}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
}
}
@ -413,3 +435,147 @@ func TestLineWrap(t *testing.T) {
})
}
}
// TestNewlineTerminatedLineClearsTrailingBg verifies that a '\n' resets
// any attributes (e.g. AttrReverse-driven background) past the line's
// content, so a reversed cell at the end doesn't bleed into the empty
// area to the right.
func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
WithSimulationScreen(t, 14, 5)
v := NewView("name", 0, 0, 11, 4, OutputNormal)
// \x1b[7m sets reverse; \x1b[31m sets fg=red. With reverse the cell
// renders with bg=red. The trailing area past "foo" must NOT extend
// the red bg because '\n' marks the line as cleanly terminated.
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
v.draw()
// First row: cells 1..3 are "foo" (render with red bg via reverse),
// cells 4..10 are trailing and should be plain default.
for x := 4; x <= 10; x++ {
_, style, _ := Screen.Get(x, 1)
assert.Equal(t, tcell.ColorDefault, style.GetForeground(),
"trailing cell at (%d, 1) should have default fg", x)
assert.False(t, style.HasReverse(),
"trailing cell at (%d, 1) should not have reverse attribute", x)
}
}
// TestUnterminatedReverseLineDoesNotExtend verifies that an unterminated
// line ending with an AttrReverse cell does NOT propagate the reversed
// background past the line's content — matching real terminal behavior
// (try `print '\x1b[7m\x1b[31mfoo'` in a shell). The trailing area
// is rendered as plain default.
func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
WithSimulationScreen(t, 14, 5)
v := NewView("name", 0, 0, 11, 4, OutputNormal)
// Reverse + red fg, "foo", no termination. The trailing cells past
// "foo" should be plain default, NOT a continuation of the red bg.
v.writeString("\x1b[7m\x1b[31mfoo")
v.draw()
// Cells 4..10 are trailing and should be default with no reverse.
for x := 4; x <= 10; x++ {
_, style, _ := Screen.Get(x, 1)
assert.Equal(t, tcell.ColorDefault, style.GetForeground(),
"trailing cell at (%d, 1) should have default fg", x)
assert.False(t, style.HasReverse(),
"trailing cell at (%d, 1) should not have reverse attribute", x)
}
}
// TestShortFilledLineExtendsBgWithoutWrap verifies that '\x1b[K' fills
// the rest of the line with the current bg color for a line that's
// short enough to fit within the view's inner width.
func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
WithSimulationScreen(t, 14, 5)
v := NewView("name", 0, 0, 11, 4, OutputNormal)
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
// fill the remaining 8 cells with red.
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
v.draw()
// All ten cells at (1..10, 1) should have red bg.
for x := 1; x <= 10; x++ {
_, style, _ := Screen.Get(x, 1)
assert.Equal(t, color.Maroon, style.GetBackground(),
"cell at (%d, 1) should have red bg", x)
}
}
// TestWrappedFilledLineExtendsBgToEdge verifies that when a line is
// filled to the edge with \x1b[K (the pattern used by `delta` for diff
// lines) but exceeds the view's inner width, every wrapped segment
// extends the fill background past its content to the right edge.
func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10,
// InnerHeight=4. Frame inset of 1 places content cells at screen
// (1..10, 1..4).
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
// Content with spaces so word wrap ends each segment before the
// right edge: "aaa bbb ccc ddd eee" wraps at InnerWidth=10 to three
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
// must pick up the red fill from \x1b[K.
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
v.draw()
// All three wrapped rows should have the red fill background across
// the full InnerWidth, including the trailing cells past each row's
// last word.
for y := 1; y <= 3; y++ {
for x := 1; x <= 10; x++ {
_, style, _ := Screen.Get(x, y)
assert.Equal(t, color.Maroon, style.GetBackground(),
"cell at (%d, %d) should have red bg", x, y)
}
}
}
// TestMulticolorWrappedFillUsesLastCellOfEachSegment demonstrates that
// when a wrapped line switches bg color part-way through and ends with
// \x1b[K, the trailing area on each wrapped row should match the bg
// that was active where that row's content ended — not the \x1b[K bg,
// which would bleed the color from the end of the logical line back
// into the earlier wrapped rows.
func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10,
// InnerHeight=4. Frame inset of 1 places content cells at screen
// (1..10, 1..4).
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
// Content "aaa bbb ccc" is 11 cells; lineWrap breaks at the space
// between "bbb" and "ccc" (index 7) so segment 1 is "aaa bbb" (red,
// last cell red) and segment 2 is "ccc" (green, last cell green).
// \x1b[K records the green bg on the source line.
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
v.draw()
// Row 1's content ends with a red cell at x=7, so trailing columns
// 8..10 should pick up red rather than the \x1b[K's green.
for x := 8; x <= 10; x++ {
_, style, _ := Screen.Get(x, 1)
assert.Equal(t, color.Maroon, style.GetBackground(),
"trailing cell at (%d, 1) should have red bg (matching segment's last cell)", x)
}
// Row 2's content ends with a green cell at x=3, so trailing
// columns 4..10 should pick up green (matching both the segment's
// last cell and the \x1b[K bg — these happen to agree here).
for x := 4; x <= 10; x++ {
_, style, _ := Screen.Get(x, 2)
assert.Equal(t, color.Green, style.GetBackground(),
"trailing cell at (%d, 2) should have green bg", x)
}
}

View file

@ -3,6 +3,7 @@ package gui
import (
"fmt"
"runtime"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
@ -13,17 +14,27 @@ import (
type BackgroundRoutineMgr struct {
gui *Gui
// if we've suspended the gui (e.g. because we've switched to a subprocess)
// we typically want to pause some things that are running like background
// file refreshes
pauseBackgroundRefreshes bool
// When this is greater than zero, the background routines (e.g. file refresh)
// skip their work. We pause them while the gui is suspended (e.g. for a
// subprocess) and while lazygit is itself driving a git operation that would
// otherwise be caught mid-flight (see the waiting-status helpers). It's a
// count rather than a bool because these pause scopes can overlap.
pauseRefreshesCount atomic.Int32
// a channel to trigger an immediate background fetch; we use this when switching repos
triggerFetch chan struct{}
}
func (self *BackgroundRoutineMgr) PauseBackgroundRefreshes(pause bool) {
self.pauseBackgroundRefreshes = pause
if pause {
self.pauseRefreshesCount.Add(1)
} else {
self.pauseRefreshesCount.Add(-1)
}
}
func (self *BackgroundRoutineMgr) backgroundRefreshesPaused() bool {
return self.pauseRefreshesCount.Load() > 0
}
func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
@ -51,6 +62,17 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
}
}
if userConfig.Git.AutoDetectExternalChanges {
interval := userConfig.Refresher.ExternalChangeCheckInterval
if interval > 0 {
go utils.Safe(self.startBackgroundExternalChangeDetection)
} else {
self.gui.c.Log.Errorf(
"Value of config option 'refresher.externalChangeCheckInterval' (%d) is invalid, disabling external change detection",
interval)
}
}
if self.gui.Config.GetDebug() {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
formatBytes := func(b uint64) string {
@ -111,11 +133,60 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
userConfig := self.gui.UserConfig()
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error {
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
return nil
})
}
func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
self.gui.waitForIntro.Wait()
// We don't seed the snapshot here. The startup refresh captures one on
// entry (like every refs-touching refresh), and until one has been
// captured RefsSnapshotChangedSince treats the empty baseline as
// "unchanged", so we never fire a spurious refresh before a baseline
// exists — no need to depend on the timing of that startup refresh.
userConfig := self.gui.UserConfig()
self.goEvery(
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
self.gui.stopChan,
func(_ bool) error {
self.checkForExternalChanges()
return nil
},
)
}
func (self *BackgroundRoutineMgr) checkForExternalChanges() {
current, err := self.gui.git.Status.RefsSnapshot()
if err != nil {
// Transient error (e.g. git process couldn't start). Don't update the
// stored snapshot; we'll retry next tick.
self.gui.c.Log.Warnf("RefsSnapshot failed: %v", err)
return
}
if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) {
return
}
// goEvery checks the pause count before starting us, but a git operation
// may have begun (and paused refreshes) after that check, while we were
// reading the snapshot above. In that case the change we detected is the
// operation's own intermediate state, so back off: the operation will
// refresh and re-snapshot when it finishes, and if the change was really
// external we'll catch it on the next tick after the pause lifts. We don't
// update the stored snapshot, so nothing is swallowed.
if self.backgroundRefreshesPaused() {
return
}
// No need to update the stored snapshot here; Refresh does that.
self.gui.c.Log.Info("External ref change detected — refreshing")
self.gui.c.Refresh(types.RefreshOptions{Background: true})
}
// returns a channel that can be used to trigger the callback immediately
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
done := make(chan struct{})
@ -124,7 +195,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
ticker := time.NewTicker(interval)
defer ticker.Stop()
doit := func(retriggered bool) {
if self.pauseBackgroundRefreshes {
if self.backgroundRefreshesPaused() {
return
}
self.gui.c.OnWorker(func(gocui.Task) error {
@ -155,7 +226,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
err = self.gui.git.Sync.FetchBackground()
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err)
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true)
}
func (self *BackgroundRoutineMgr) triggerImmediateFetch() {

View file

@ -594,7 +594,11 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
}
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}

View file

@ -0,0 +1,36 @@
package controllers
import (
"errors"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type EditConfigAction struct {
c *ControllerCommon
}
func (self *EditConfigAction) Call() error {
confPaths := self.c.GetConfig().GetUserConfigPaths()
switch len(confPaths) {
case 0:
return errors.New(self.c.Tr.NoConfigFileFoundErr)
case 1:
return self.c.Helpers().Files.EditFiles(confPaths)
default:
menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem {
return &types.MenuItem{
Label: path,
OnPress: func() error {
return self.c.Helpers().Files.EditFiles([]string{path})
},
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.SelectConfigFile,
Items: menuItems,
})
}
}

View file

@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.withItems(self.press),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageTooltip,
DisplayOnScreen: true,
@ -259,100 +259,157 @@ func (self *FilesController) GetOnRenderToMain() func() {
node := self.context().GetSelected()
if node == nil {
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.DiffTitle,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles),
},
})
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
return
}
if self.isSubmoduleCommitConflict(node.File) {
self.renderSubmoduleConflict(node)
return
}
if node.File != nil && node.File.HasInlineMergeConflicts {
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
if err != nil {
return
}
if hasConflicts {
self.c.Helpers().MergeConflicts.Render()
if self.renderInlineMergeConflict(node) {
return
}
// The file is marked as conflicted but has no conflict markers (it
// was resolved in an editor), so fall through to show its diff.
} else if node.File != nil && node.File.HasMergeConflicts {
opts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.DiffTitle,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
},
}
message := node.File.GetMergeStateDescription(self.c.Tr)
message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
self.c.UserConfig().Keybinding.Universal.GoInto)
if self.c.Views().Main.InnerWidth() > 70 {
// If the main view is very wide, wrap the message to increase readability
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
message = strings.Join(lines, "\n")
}
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
prefix := message + "\n\n"
if node.File.ShortStatus == "DU" {
prefix += self.c.Tr.MergeConflictIncomingDiff
} else {
prefix += self.c.Tr.MergeConflictCurrentDiff
}
prefix += "\n\n"
opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
} else {
opts.Main.Task = types.NewRenderStringTask(message)
}
self.c.RenderToMainViews(opts)
self.renderNonTextualConflict(node)
return
}
self.c.Helpers().MergeConflicts.ResetMergeState()
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
}
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
title = self.c.Tr.UnstagedChanges
}
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
}
}
self.c.RenderToMainViews(refreshOpts)
self.renderWorkingTreeDiff(node)
})
}
}
// renderToMainWithTask renders the given task to the main view with the standard
// diff title and subtitle.
func (self *FilesController) renderToMainWithTask(task types.UpdateTask) {
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.DiffTitle,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
})
}
// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus
// the commits each side added relative to their common ancestor as two separate,
// indented logs. If a side added nothing of its own (e.g. it was rewound to an
// ancestor of the other), the commit it points at is shown instead.
func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
self.c.Helpers().MergeConflicts.ResetMergeState()
path := node.GetPath()
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
if err != nil {
return
}
sideBlock := func(header string, side string, otherSide string) string {
log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide)
if err != nil {
return header
}
if log = strings.TrimRight(log, "\n"); log == "" {
if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil {
return header
}
}
return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ")
}
message := strings.Join([]string{
self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})),
sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs),
sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours),
}, "\n\n")
self.renderToMainWithTask(types.NewRenderStringTask(message))
}
// renderInlineMergeConflict renders the merge-conflict view for a file with
// inline conflict markers. It returns false if the file has no actual markers
// (it was resolved in an editor), in which case the caller should fall back to
// showing the file's diff.
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
if err != nil {
return true
}
if !hasConflicts {
return false
}
self.c.Helpers().MergeConflicts.Render()
return true
}
// renderNonTextualConflict shows the resolution hint for a non-textual text-file
// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases.
func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
prefix := message + "\n\n"
if node.File.ShortStatus == "DU" {
prefix += self.c.Tr.MergeConflictIncomingDiff
} else {
prefix += self.c.Tr.MergeConflictCurrentDiff
}
prefix += "\n\n"
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
return
}
self.renderToMainWithTask(types.NewRenderStringTask(message))
}
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
self.c.Helpers().MergeConflicts.ResetMergeState()
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
}
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
title = self.c.Tr.UnstagedChanges
}
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
}
}
self.c.RenderToMainViews(refreshOpts)
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
@ -583,6 +640,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
}
func (self *FilesController) press(nodes []*filetree.FileNode) error {
// A single file with a conflict that can only be resolved through a dialog
// can't be staged; route it to the same picker that `enter` uses instead.
if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) {
return self.openConflictResolutionMenu(nodes[0].File)
}
if err := self.pressWithLock(nodes); err != nil {
return err
}
@ -683,6 +746,10 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
file := node.File
if self.conflictNeedsResolutionDialog(file) {
return self.openConflictResolutionMenu(file)
}
submoduleConfigs := self.c.Model().Submodules
if file.IsSubmodule(submoduleConfigs) {
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
@ -692,9 +759,6 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
if file.HasInlineMergeConflicts {
return self.switchToMerge()
}
if file.HasMergeConflicts {
return self.handleNonInlineConflict(file)
}
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
self.c.Context().Push(context, opts)
@ -703,7 +767,77 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
return nil
}
func (self *FilesController) handleNonInlineConflict(file *models.File) error {
// conflictResolutionHint formats a conflict description for the main view,
// appending the "press <enter> to resolve" hint and wrapping it when the view is
// wide enough that long lines would otherwise hurt readability.
func (self *FilesController) conflictResolutionHint(description string) string {
message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
self.c.UserConfig().Keybinding.Universal.GoInto)
if self.c.Views().Main.InnerWidth() > 70 {
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
message = strings.Join(lines, "\n")
}
return message
}
// conflictNeedsResolutionDialog reports whether a file's merge conflict can only
// be resolved through a dialog that picks one side, as opposed to editing
// conflict markers in the merge view. These are the "non-textual" conflicts:
// text files where one side modified and the other deleted/renamed the file
// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU).
func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool {
if file == nil || !file.HasMergeConflicts {
return false
}
// A conflicted submodule has no conflict markers to edit; it's resolved by
// picking which commit to point at.
if file.IsSubmodule(self.c.Model().Submodules) {
return true
}
return !file.HasInlineMergeConflicts
}
// canStageSelection disables staging when a multiple selection includes a file
// with a conflict that must be resolved through a dialog; those have to be
// resolved one at a time.
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
if len(nodes) > 1 {
for _, node := range nodes {
if node.SomeFile(self.conflictNeedsResolutionDialog) {
return &types.DisabledReason{
Text: utils.ResolvePlaceholderString(
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
},
),
}
}
}
}
return nil
}
// isSubmoduleCommitConflict reports whether the file is a submodule whose commit
// pointer conflicts (status UU or AA): both sides recorded a different commit,
// with no base content to merge. These are resolved by picking one side's
// commit. Other submodule conflicts (e.g. modify/delete) are handled like
// ordinary non-textual conflicts, with the keep/delete picker.
func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool {
return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules)
}
func (self *FilesController) openConflictResolutionMenu(file *models.File) error {
if self.isSubmoduleCommitConflict(file) {
return self.openSubmoduleConflictMenu(file)
}
return self.openFileConflictMenu(file)
}
func (self *FilesController) openFileConflictMenu(file *models.File) error {
handle := func(command func(command string) error, logText string) error {
self.c.LogAction(logText)
if err := command(file.GetPath()); err != nil {
@ -750,6 +884,52 @@ func (self *FilesController) handleNonInlineConflict(file *models.File) error {
})
}
func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error {
path := file.GetPath()
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
if err != nil {
return err
}
resolve := func(sha string, logAction string) error {
self.c.LogAction(logAction)
if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil {
return err
}
if err := self.c.Git().WorkingTree.StageFile(path); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return nil
}
// Append the commit summary to the label so the user can tell the two
// candidates apart, falling back to the bare label if we can't read it.
label := func(text string, sha string) string {
if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" {
return fmt.Sprintf("%s (%s)", text, summary)
}
return text
}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.MergeConflictsTitle,
Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}),
Items: []*types.MenuItem{
{
Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours),
OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) },
Keys: menuKey('c'),
},
{
Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs),
OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) },
Keys: menuKey('i'),
},
},
})
}
func (self *FilesController) toggleStagedAll() error {
if err := self.toggleStagedAllWithLock(); err != nil {
return err
@ -1372,7 +1552,7 @@ func (self *FilesController) fetch() error {
return errors.New(self.c.Tr.PassUnameWrong)
}
return self.c.Helpers().BranchesHelper.PostFetchRefresh(err)
return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false)
})
}

View file

@ -136,6 +136,12 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
Description: self.c.Tr.ToggleWhitespaceInDiffView,
Tooltip: self.c.Tr.ToggleWhitespaceInDiffViewTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.EditConfig),
Handler: self.editConfig,
Description: self.c.Tr.EditConfig,
Tooltip: self.c.Tr.EditFileTooltip,
},
}
}
@ -267,6 +273,10 @@ func (self *GlobalController) toggleWhitespace() error {
return (&ToggleWhitespaceAction{c: self.c}).Call()
}
func (self *GlobalController) editConfig() error {
return (&EditConfigAction{c: self.c}).Call()
}
func (self *GlobalController) canShowRebaseOptions() *types.DisabledReason {
if self.c.Model().WorkingTreeStateAtLastCommitRefresh.None() {
return &types.DisabledReason{

View file

@ -66,12 +66,22 @@ func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task
}
func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error {
// A waiting status means lazygit is driving a git operation itself (often
// one that internally runs a rebase and continues it). Pause the background
// routines for its duration so they don't refresh from an intermediate
// state and reveal, say, the half-finished history of a reword.
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error {
return f(appStatusHelperTask{task, waitingStatusHandle})
})
}
func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error {
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error {
stop := make(chan struct{})
defer func() { close(stop) }()

View file

@ -285,7 +285,7 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote
return nil
}
func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error {
func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) error {
scope := []types.RefreshableView{
types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS,
}
@ -293,7 +293,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error {
if self.c.UserConfig().Git.AutoForwardBranches != "none" {
scope = append(scope, types.WORKTREES)
}
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC})
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background})
if fetchErr != nil {
return fetchErr
}

View file

@ -100,16 +100,6 @@ func (self *CherryPickHelper) Paste() error {
return result
}
// Move the selection down by the number of commits we just
// cherry-picked, to keep the same commit selected as before.
// Don't do this if a rebase todo is selected, because in this
// case we are in a rebase and the cherry-picked commits end up
// below the selection.
if commit := self.c.Contexts().LocalCommits.GetSelected(); commit != nil && !commit.IsTODO() {
self.c.Contexts().LocalCommits.MoveSelection(len(cherryPickedCommits))
self.c.Contexts().LocalCommits.FocusLine(true)
}
// If we're in the cherry-picking state at this point, it must
// be because there were conflicts. Don't clear the copied
// commits in this case, since we might want to abort and try

View file

@ -19,11 +19,45 @@ func NewGpgHelper(c *HelperCommon) *GpgHelper {
}
}
func (self *GpgHelper) WithGpgHandling(
cmdObj *oscommands.CmdObj,
configKey git_commands.GpgConfigKey,
waitingStatus string,
onSuccess func() error,
refreshScope []types.RefreshableView,
) error {
refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions)
}
// WithGpgHandlingAndSelectHeadCommit is like WithGpgHandling, but on success it
// selects the new HEAD commit rather than restoring the previous selection. For
// committing, where the commit we just created is the one we want selected.
func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit(
cmdObj *oscommands.CmdObj,
configKey git_commands.GpgConfigKey,
waitingStatus string,
onSuccess func() error,
) error {
failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC}
successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
}
// Currently there is a bug where if we switch to a subprocess from within
// WithWaitingStatus we get stuck there and can't return to lazygit. We could
// fix this bug, or just stop running subprocesses from within there, given that
// we don't need to see a loading status if we're in a subprocess.
func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
func (self *GpgHelper) withGpgHandling(
cmdObj *oscommands.CmdObj,
configKey git_commands.GpgConfigKey,
waitingStatus string,
onSuccess func() error,
failureRefreshOptions types.RefreshOptions,
successRefreshOptions types.RefreshOptions,
) error {
useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey)
if useSubprocess {
success, err := self.c.RunSubprocess(cmdObj)
@ -32,18 +66,29 @@ func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_
return err
}
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
if success {
self.c.Refresh(successRefreshOptions)
} else {
self.c.Refresh(failureRefreshOptions)
}
return err
}
return self.runAndStream(cmdObj, waitingStatus, onSuccess, refreshScope)
return self.runAndStream(
cmdObj, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
}
func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
func (self *GpgHelper) runAndStream(
cmdObj *oscommands.CmdObj,
waitingStatus string,
onSuccess func() error,
failureRefreshOptions types.RefreshOptions,
successRefreshOptions types.RefreshOptions,
) error {
return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error {
if err := cmdObj.StreamOutput().Run(); err != nil {
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
self.c.Refresh(failureRefreshOptions)
return fmt.Errorf(
self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu,
)
@ -55,7 +100,7 @@ func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus str
}
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
self.c.Refresh(successRefreshOptions)
return nil
})
}

View file

@ -68,6 +68,14 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g
visible := view.Visible && self.windowHelper.TopViewInWindow(context.GetWindowName(), false) == view
if visible && context.IsItemVisible(opts.Item) {
self.c.OnWorker(func(task gocui.Task) error {
// An inline status is just a waiting status rendered on the item
// rather than in the bottom line, so it gets the same treatment:
// pause the background routines while we drive the operation. (The
// off-screen branch below goes through WithWaitingStatus, which
// already does this.)
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
self.start(opts)
defer self.stop(opts)

View file

@ -95,6 +95,8 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
}
commandType := status.CommandName()
selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE &&
effectiveStatus == models.WORKING_TREE_STATE_MERGING
// we should end up with a command like 'git merge --continue'
@ -106,15 +108,29 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
if needsSubprocess {
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
return self.c.RunSubprocessAndRefresh(
self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command),
)
}
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
if err := self.CheckMergeOrRebase(result); err != nil {
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command))
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
})
return err
}
return nil
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{
Mode: types.ASYNC,
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
})
}
// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new
// commit at HEAD to the corresponding commit-selection behavior: select that
// new commit, or otherwise keep the previous selection by hash.
func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehavior {
if createdNewCommit {
return types.SelectHeadCommit
}
return types.KeepCommitSelectionByHash
}
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
@ -169,6 +185,15 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC})
}
// Like CheckMergeOrRebase, but for operations that create a new commit at HEAD
// (a merge, or a pull that merges): on success it selects that new commit,
// which the keep-selection-by-hash logic can't do since the commit didn't exist
// before the refresh.
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)})
}
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
if result == nil {
return nil
@ -492,7 +517,7 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_comma
return func() error {
self.c.LogAction(self.c.Tr.Actions.Merge)
err := self.c.Git().Branch.Merge(refName, variant)
return self.CheckMergeOrRebase(err)
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
@ -19,6 +20,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
)
type RefreshHelper struct {
@ -36,6 +38,12 @@ type RefreshHelper struct {
// Keyed by repo path so that switching to a different repo while lazygit is running
// still triggers the prompt there.
githubBaseRemotePromptDismissed map[string]bool
// Last observed refs+HEAD fingerprint, used by the background poller to
// decide whether a real refresh is needed. Written at the end of every
// refresh that re-read refs/commits, read by the poller.
refsSnapshotMutex deadlock.Mutex
refsSnapshot string
}
func NewRefreshHelper(
@ -106,6 +114,30 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
scopeSet = set.NewFromSlice(options.Scope)
}
// Expand co-refreshing scopes up front so downstream conditions can be
// simple single-scope checks. The relationships are:
// - whenever the reflog or bisect info changes, commits and branches
// can change too (e.g. switching branches updates the reflog and
// can move HEAD), so refresh commits + branches alongside
// - submodules are refreshed as part of the files refresh
// - merge conflicts are part of what the files refresh produces
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
if scopeSet.Includes(types.SUBMODULES) {
scopeSet.Add(types.FILES)
}
if scopeSet.Includes(types.FILES) {
scopeSet.Add(types.MERGE_CONFLICTS)
}
// Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race
// of git's state changing externally while (or right after) we are
// refreshing; the risk is one potential extra refresh, but capturing the
// snapshot at the end would risk missing one, which is worse.
self.updateRefsSnapshotIfRelevant(scopeSet)
wg := sync.WaitGroup{}
refresh := func(name string, f func()) {
// if we're in a demo we don't want any async refreshes because
@ -129,11 +161,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
branchesAndRemotesWg := sync.WaitGroup{}
includeWorktreesWithBranches := false
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
// whenever we change commits, we should update branches because the upstream/downstream
// counts can change. Whenever we change branches we should also change commits
// e.g. in the case of switching branches.
refresh("commits and commit files", self.refreshCommitsAndCommitFiles)
refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(options.CommitSelection)
})
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
@ -166,10 +200,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
}
fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) {
if scopeSet.Includes(types.FILES) {
fileWg.Add(1)
refresh("files", func() {
_ = self.refreshFilesAndSubmodules()
_ = self.refreshFilesAndSubmodules(options.Background)
fileWg.Done()
})
}
@ -212,7 +246,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) })
}
if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) {
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() })
}
@ -236,6 +270,57 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
f()
}
// SetRefsSnapshot stores the given snapshot as the last observed refs state.
// Called externally by the background poller at startup to seed the snapshot,
// and internally by Refresh at the end of a refs-touching refresh.
func (self *RefreshHelper) SetRefsSnapshot(snapshot string) {
self.refsSnapshotMutex.Lock()
defer self.refsSnapshotMutex.Unlock()
self.refsSnapshot = snapshot
}
// RefsSnapshotChangedSince reports whether the given snapshot differs from
// the last observed one. Pure read; does not update internal state.
func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool {
self.refsSnapshotMutex.Lock()
defer self.refsSnapshotMutex.Unlock()
// An empty stored snapshot means no refresh has captured one yet, so we
// have no baseline to compare against and report "unchanged" rather than
// firing a spurious refresh. This can only be the unset zero value: a
// snapshot we actually computed is never empty, because its HEAD component
// is always non-empty (a branch ref when attached, a hash when detached —
// even a repo with no commits yields "ref: refs/heads/main").
if self.refsSnapshot == "" {
return false
}
return snapshot != self.refsSnapshot
}
// updateRefsSnapshotIfRelevant captures a fresh refs snapshot from disk at the
// start of a refresh that re-reads refs/commits (see the call site for why we
// capture before reading the model rather than after). This keeps the
// background poller's stored snapshot in sync with what's been observed by the
// UI, so in-app commands and focus-in refreshes don't cause the next poll to
// spuriously re-trigger.
//
// We check just COMMITS and BRANCHES because the scope-expansion step at the
// top of Refresh has already added these whenever REFLOG or BISECT_INFO are
// in scope, and whenever a nil scope was passed.
func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) {
if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) {
return
}
snapshot, err := self.c.Git().Status.RefsSnapshot()
if err != nil {
self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err)
return
}
self.SetRefsSnapshot(snapshot)
}
func getScopeNames(scopes []types.RefreshableView) []string {
scopeNameMap := map[types.RefreshableView]string{
types.COMMITS: "commits",
@ -303,8 +388,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepB
self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts)
}
func (self *RefreshHelper) refreshCommitsAndCommitFiles() {
_ = self.refreshCommitsWithLimit()
func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) {
_ = self.refreshCommitsWithLimit(commitSelection)
ctx := self.c.Contexts().CommitFiles.GetParentContext()
if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY {
// This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position.
@ -348,10 +433,16 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref {
return nil
}
func (self *RefreshHelper) refreshCommitsWithLimit() error {
func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error {
self.c.Mutexes().LocalCommitsMutex.Lock()
defer self.c.Mutexes().LocalCommitsMutex.Unlock()
var selectionRange *localCommitSelectionRange
if commitSelection == types.KeepCommitSelectionByHash {
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
}
checkedOutRef := self.determineCheckedOutRef()
commits, err := self.c.Git().Loaders.CommitLoader.GetCommits(
git_commands.GetCommitsOptions{
@ -378,10 +469,110 @@ func (self *RefreshHelper) refreshCommitsWithLimit() error {
self.c.Model().CheckedOutBranch = ""
}
scrollSelectionIntoView := false
switch commitSelection {
case types.SelectHeadCommit:
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
scrollSelectionIntoView = true
}
case types.KeepCommitSelectionByHash:
if selectionRange != nil {
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
if found {
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
scrollSelectionIntoView = didMove
}
}
case types.KeepCommitSelectionIndex:
// The caller set the selection index deliberately; leave it untouched.
}
self.refreshView(self.c.Contexts().LocalCommits)
if scrollSelectionIntoView {
self.c.OnUIThread(func() error {
self.c.Contexts().LocalCommits.FocusLine(true)
return nil
})
}
return nil
}
type localCommitSelectionRange struct {
selectedHash string
selectedIsTODO bool
rangeStartHash string
rangeStartIsTODO bool
selectedIdx int
rangeStartIdx int
mode traits.RangeSelectMode
}
func captureLocalCommitSelectionRange(
commits []*models.Commit,
selectedIdx int,
rangeStartIdx int,
mode traits.RangeSelectMode,
) *localCommitSelectionRange {
if !hasRestorableCommitHash(commits, selectedIdx) || !hasRestorableCommitHash(commits, rangeStartIdx) {
return nil
}
return &localCommitSelectionRange{
selectedHash: commits[selectedIdx].Hash(),
selectedIsTODO: commits[selectedIdx].IsTODO(),
rangeStartHash: commits[rangeStartIdx].Hash(),
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
mode: mode,
}
}
func findLocalCommitSelectionRange(
commits []*models.Commit,
selectionRange *localCommitSelectionRange,
) (int, int, bool, bool) {
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
if !foundSelected || !foundRangeStart {
return 0, 0, false, false
}
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
return selectedIdx, rangeStartIdx, didMove, true
}
// findCommitByHashPreferringTODOStatus finds the commit with the given hash.
// When both a TODO and a non-TODO commit share that hash - which happens while
// reverting or cherry-picking, where the rebase TODO entry has the same hash as
// the real commit - it returns the one whose TODO status matches isTODO. When
// only one commit has the hash, it is returned regardless of its TODO status,
// so that a selected commit which turned into a TODO entry across the refresh is
// still found (e.g. when starting an interactive rebase that stops to edit it).
func findCommitByHashPreferringTODOStatus(commits []*models.Commit, hash string, isTODO bool) (int, bool) {
fallbackIdx := -1
for idx, commit := range commits {
if commit.Hash() != hash {
continue
}
if commit.IsTODO() == isTODO {
return idx, true
}
if fallbackIdx == -1 {
fallbackIdx = idx
}
}
return fallbackIdx, fallbackIdx != -1
}
func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
}
func (self *RefreshHelper) refreshSubCommitsWithLimit() error {
if self.c.Contexts().SubCommits.GetRef() == nil {
return nil
@ -542,7 +733,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
self.refreshStatus()
}
func (self *RefreshHelper) refreshFilesAndSubmodules() error {
func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error {
self.c.Mutexes().RefreshingFilesMutex.Lock()
self.c.State().SetIsRefreshingFiles(true)
defer func() {
@ -554,7 +745,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
return err
}
if err := self.refreshStateFiles(); err != nil {
if err := self.refreshStateFiles(background); err != nil {
return err
}
@ -567,7 +758,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
return nil
}
func (self *RefreshHelper) refreshStateFiles() error {
func (self *RefreshHelper) refreshStateFiles(background bool) error {
fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel
prevConflictFileCount := 0
@ -605,6 +796,7 @@ func (self *RefreshHelper) refreshStateFiles() error {
files := self.c.Git().Loaders.FileLoader.
GetStatusFiles(git_commands.GetStatusFileOptions{
ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(),
Background: background,
})
conflictFileCount := 0

View file

@ -5,10 +5,161 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestCaptureLocalCommitSelectionRange(t *testing.T) {
testCases := []struct {
name string
commits []*models.Commit
selectedIdx int
rangeStartIdx int
expected *localCommitSelectionRange
}{
{
name: "captures selected commit and range start",
commits: makeCommits("a", "b"),
selectedIdx: 1,
rangeStartIdx: 0,
expected: &localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "a",
selectedIdx: 1,
rangeStartIdx: 0,
mode: traits.RangeSelectModeSticky,
},
},
{
name: "ignores invalid range start index",
commits: makeCommits("a"),
selectedIdx: 0,
rangeStartIdx: 1,
expected: nil,
},
{
name: "ignores empty selected hash",
commits: append(makeCommits("a"), makeTodoCommit(todo.UpdateRef)),
selectedIdx: 1,
rangeStartIdx: 0,
expected: nil,
},
{
name: "ignores empty range start hash",
commits: append(makeCommits("a"), makeTodoCommit(todo.Exec)),
selectedIdx: 0,
rangeStartIdx: 1,
expected: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectionRange := captureLocalCommitSelectionRange(
testCase.commits,
testCase.selectedIdx,
testCase.rangeStartIdx,
traits.RangeSelectModeSticky,
)
assert.Equal(t, testCase.expected, selectionRange)
})
}
}
func TestFindLocalCommitSelectionRange(t *testing.T) {
type expectation struct {
selectedIdx int
rangeStartIdx int
moved bool
found bool
}
selectionRange := localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "c",
selectedIdx: 1,
rangeStartIdx: 2,
mode: traits.RangeSelectModeSticky,
}
testCases := []struct {
name string
commits []*models.Commit
expected expectation
}{
{
name: "finds selection after commits are inserted above it",
commits: makeCommits("new", "a", "b", "c"),
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
moved: true,
found: true,
},
},
{
name: "finds selection that did not move",
commits: makeCommits("a", "b", "c"),
expected: expectation{
selectedIdx: 1,
rangeStartIdx: 2,
found: true,
},
},
{
name: "reports not found when a hash is missing",
commits: makeCommits("a", "b"),
expected: expectation{},
},
{
name: "skips todo entries with the same hash as a selected commit",
commits: []*models.Commit{
makeTodoCommitWithHash("b", todo.Revert),
makeCommits("a")[0],
makeCommits("b")[0],
makeCommits("c")[0],
},
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
moved: true,
found: true,
},
},
{
name: "falls back to a todo entry when the selected commit became one",
commits: []*models.Commit{
makeTodoCommitWithHash("b", todo.Pick),
makeCommits("c")[0],
},
expected: expectation{
selectedIdx: 0,
rangeStartIdx: 1,
moved: true,
found: true,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
actual := expectation{
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
moved: moved,
found: found,
}
assert.Equal(t, testCase.expected, actual)
})
}
}
func TestGetGithubBaseRemote(t *testing.T) {
cases := []struct {
name string
@ -122,3 +273,18 @@ func makeAuthenticatedGithubRemoteInfo(name string, webDomain string, authToken
info.authToken = authToken
return info
}
func makeCommits(hashes ...string) []*models.Commit {
hashPool := &utils.StringPool{}
return lo.Map(hashes, func(hash string, _ int) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash})
})
}
func makeTodoCommit(action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Action: action})
}
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
}

View file

@ -66,7 +66,12 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
if options.RefreshPullRequests {
scope = append(scope, types.PULL_REQUESTS)
}
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, Scope: scope, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
Scope: scope,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
}
localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
@ -209,7 +214,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
// loading a heap of commits is slow so we limit them whenever doing a reset
self.c.Contexts().LocalCommits.SetLimitCommits(true)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex})
return nil
}
@ -370,7 +375,11 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
}
self.c.Prompt(types.PromptOpts{
@ -525,7 +534,11 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
@ -563,7 +576,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}

View file

@ -50,6 +50,11 @@ type WindowArrangementArgs struct {
// Name of the current side window (i.e. the current window in the left
// section of the UI)
CurrentSideWindow string
// Returns the view currently shown in the given window. When a window holds
// several tabbed views this is the selected tab, which is what the status and
// stash height special-cases key off (rather than the window itself, whose
// name is just its first tab).
ActiveViewForWindow func(window string) string
// Whether the main panel is split (as is the case e.g. when a file has both
// staged and unstaged changes)
SplitMainPanel bool
@ -86,20 +91,21 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
}
args := WindowArrangementArgs{
Width: width,
Height: height,
UserConfig: self.c.UserConfig(),
CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(),
CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(),
SplitMainPanel: repoState.GetSplitMainPanel(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
ShowExtrasWindow: self.c.State().GetShowExtrasWindow(),
InDemo: self.c.InDemo(),
IsAnyModeActive: self.modeHelper.IsAnyModeActive(),
InSearchPrompt: repoState.InSearchPrompt(),
SearchPrefix: searchPrefix,
Width: width,
Height: height,
UserConfig: self.c.UserConfig(),
CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(),
CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(),
ActiveViewForWindow: self.windowHelper.GetViewNameForWindow,
SplitMainPanel: repoState.GetSplitMainPanel(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
ShowExtrasWindow: self.c.State().GetShowExtrasWindow(),
InDemo: self.c.InDemo(),
IsAnyModeActive: self.modeHelper.IsAnyModeActive(),
InSearchPrompt: repoState.InSearchPrompt(),
SearchPrefix: searchPrefix,
}
return GetWindowDimensions(args)
@ -403,14 +409,15 @@ func getExtrasWindowSize(args WindowArrangementArgs) int {
return baseSize + frameSize
}
// The stash window by default only contains one line so that it's not hogging
// The stash view by default only contains one line so that it's not hogging
// too much space, but if you access it it should take up some space. This is
// the default behaviour when accordion mode is NOT in effect. If it is in effect
// then when it's accessed it will have weight 2, not 1.
func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
box := &boxlayout.Box{Window: "stash"}
// if the stash window is anywhere in our stack we should enlargen it
if args.CurrentSideWindow == "stash" {
// then when it's accessed it will have weight 2, not 1. The window is passed in
// because stash may be a tab of a window named after a different first tab.
func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlayout.Box {
box := &boxlayout.Box{Window: window}
// if the window showing stash is focused we should enlargen it
if args.CurrentSideWindow == window {
box.Weight = 1
} else {
box.Size = 3
@ -421,6 +428,16 @@ func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box {
return func(width int, height int) []*boxlayout.Box {
windows := sideWindowNames(args.UserConfig)
boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box {
boxes := make([]*boxlayout.Box, 0, len(windows))
for _, window := range windows {
boxes = append(boxes, boxForWindow(window))
}
return boxes
}
if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF {
fullHeightBox := func(window string) *boxlayout.Box {
if window == args.CurrentSideWindow {
@ -436,13 +453,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
}
}
return []*boxlayout.Box{
fullHeightBox("status"),
fullHeightBox("files"),
fullHeightBox("branches"),
fullHeightBox("commits"),
fullHeightBox("stash"),
}
return boxForEachWindow(fullHeightBox)
} else if height >= 28 {
accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
@ -456,16 +467,23 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
return defaultBox
}
return []*boxlayout.Box{
{
Window: "status",
Size: 3,
},
accordionBox(&boxlayout.Box{Window: "files", Weight: 1}),
accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}),
accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}),
accordionBox(getDefaultStashWindowBox(args)),
normalBox := func(window string) *boxlayout.Box {
// The status and stash sizing is a property of those views, so we key
// off the tab the window is currently showing, not the window's name
// (its first tab): otherwise grouping other tabs behind status or
// stash would wrongly impose their compact height on those tabs.
switch args.ActiveViewForWindow(window) {
case "status":
// The status view has a fixed height and is not expanded by accordion mode.
return &boxlayout.Box{Window: window, Size: 3}
case "stash":
return accordionBox(getDefaultStashWindowBox(args, window))
default:
return accordionBox(&boxlayout.Box{Window: window, Weight: 1})
}
}
return boxForEachWindow(normalBox)
}
squashedHeight := 1
@ -487,12 +505,6 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [
}
}
return []*boxlayout.Box{
squashedSidePanelBox("status"),
squashedSidePanelBox("files"),
squashedSidePanelBox("branches"),
squashedSidePanelBox("commits"),
squashedSidePanelBox("stash"),
}
return boxForEachWindow(squashedSidePanelBox)
}
}

View file

@ -24,15 +24,18 @@ func TestGetWindowDimensions(t *testing.T) {
UserConfig: config.GetDefaultConfig(),
CurrentWindow: "files",
CurrentSideWindow: "files",
SplitMainPanel: false,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
ShowExtrasWindow: false,
InDemo: false,
IsAnyModeActive: false,
InSearchPrompt: false,
SearchPrefix: "",
// Each panel shows its first tab by default; for the special-cased
// panels (status, stash) the view name matches the window name.
ActiveViewForWindow: func(window string) string { return window },
SplitMainPanel: false,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
ShowExtrasWindow: false,
InDemo: false,
IsAnyModeActive: false,
InSearchPrompt: false,
SearchPrefix: "",
}
}
@ -121,6 +124,152 @@ func TestGetWindowDimensions(t *testing.T) {
B: information
`,
},
{
name: "worktrees promoted to its own side panel",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "submodules"},
{"worktrees"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
{"stash"},
}
},
expected: `
statusmain
files
worktrees
branches
commits
stash
<options>A<B>
A: statusSpacer1
B: information
`,
},
{
name: "stash side panel hidden",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
}
},
expected: `
statusmain
files
branches
commits
<options>A<B>
A: statusSpacer1
B: information
`,
},
{
name: "stash leading a grouped panel doesn't squash its other tabs",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"stash", "branches", "remotes", "tags"},
{"commits", "reflog"},
}
// The third panel is named after its first tab, stash, but is
// currently showing the branches tab, which must get full height
// rather than stash's compact height.
args.ActiveViewForWindow = func(window string) string {
if window == "stash" {
return "branches"
}
return window
}
},
expected: `
statusmain
files
stash
commits
<options>A<B>
A: statusSpacer1
B: information
`,
},
{
name: "expandFocusedSidePanel",
mutateArgs: func(args *WindowArrangementArgs) {

View file

@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@ -135,5 +136,13 @@ func (self *WindowHelper) WindowForView(viewName string) string {
}
func (self *WindowHelper) SideWindows() []string {
return []string{"status", "files", "branches", "commits", "stash"}
return sideWindowNames(self.c.UserConfig())
}
// sideWindowNames returns the side panel window names in order, derived from the
// gui.sidePanels config. A panel's window name is the name of its first tab.
func sideWindowNames(userConfig *config.UserConfig) []string {
return lo.Map(userConfig.Gui.SidePanels, func(panel config.SidePanel, _ int) string {
return panel[0]
})
}

View file

@ -147,11 +147,11 @@ func (self *WorkingTreeHelper) HandleCommitPressWithMessage(initialMessage strin
func (self *WorkingTreeHelper) handleCommit(summary string, description string, forceSkipHooks bool) error {
cmdObj := self.c.Git().Commit.CommitCmdObj(summary, description, forceSkipHooks)
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.gpgHelper.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
func() error {
self.commitsHelper.ClearPreservedCommitMessage()
return nil
}, nil)
})
}
func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath string, forceSkipHooks bool) error {

View file

@ -1,10 +1,7 @@
package controllers
import (
"log"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type JumpToSideWindowController struct {
@ -30,19 +27,23 @@ func (self *JumpToSideWindowController) Context() types.Context {
func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
windows := self.c.Helpers().Window.SideWindows()
jumpKeys := opts.Config.Universal.JumpToBlock
if len(opts.Config.Universal.JumpToBlock) != len(windows) {
log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.")
}
return lo.Map(windows, func(window string, index int) *types.Binding {
return &types.Binding{
// Assign jump keys to panels positionally (by default 1 to the first panel,
// 2 to the second, etc.), for as many panels as there are keys. If there are
// more panels than keys the extra panels just have no jump key, and if there
// are more keys than panels the extra keys are unused; either way panels stay
// reachable via the next/previous-panel keys.
count := min(len(windows), len(jumpKeys))
bindings := make([]*types.Binding, 0, count)
for i := range count {
bindings = append(bindings, &types.Binding{
ViewName: "",
// by default the keys are 1, 2, 3, etc
Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]),
Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)),
}
})
Keys: opts.GetKeys(jumpKeys[i]),
Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(windows[i])),
})
}
return bindings
}
func (self *JumpToSideWindowController) goToSideWindow(window string) func() error {

View file

@ -8,7 +8,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
@ -590,15 +589,9 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits
if !commits[endIdx].IsMerge() {
selectionRangeAndMode := self.getSelectionRangeAndMode()
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err,
types.RefreshOptions{
Mode: types.BLOCK_UI, Then: func() {
self.restoreSelectionRangeAndMode(selectionRangeAndMode)
},
})
err, types.RefreshOptions{Mode: types.BLOCK_UI})
}
return self.startInteractiveRebaseWithEdit(selectedCommits)
@ -618,7 +611,6 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
selectionRangeAndMode := self.getSelectionRangeAndMode()
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err,
@ -636,42 +628,10 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
self.c.Log.Errorf("error when updating todos: %v", err)
}
}
self.restoreSelectionRangeAndMode(selectionRangeAndMode)
}})
})
}
type SelectionRangeAndMode struct {
selectedHash string
rangeStartHash string
mode traits.RangeSelectMode
}
func (self *LocalCommitsController) getSelectionRangeAndMode() SelectionRangeAndMode {
selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode()
commits := self.c.Model().Commits
selectedHash := commits[selectedIdx].Hash()
rangeStartHash := commits[rangeStartIdx].Hash()
return SelectionRangeAndMode{selectedHash, rangeStartHash, rangeSelectMode}
}
func (self *LocalCommitsController) restoreSelectionRangeAndMode(selectionRangeAndMode SelectionRangeAndMode) {
// We need to select the same commit range again because after starting a rebase,
// new lines can be added for update-ref commands in the TODO file, due to
// stacked branches. So the selected commits may be in different positions in the list.
_, newSelectedIdx, ok1 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
return c.Hash() == selectionRangeAndMode.selectedHash
})
_, newRangeStartIdx, ok2 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
return c.Hash() == selectionRangeAndMode.rangeStartHash
})
if ok1 && ok2 {
self.context().SetSelectionRangeAndMode(newSelectedIdx, newRangeStartIdx, selectionRangeAndMode.mode)
self.context().HandleFocus(types.OnFocusOpts{})
}
}
func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) {
commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
return c.IsMerge() || c.Status == models.StatusMerged
@ -767,7 +727,9 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
@ -780,7 +742,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC})
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
})
}
@ -793,7 +755,9 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
@ -806,7 +770,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC})
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
})
}
@ -966,8 +930,6 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil {
return err
}
self.context().MoveSelection(len(commits))
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
if mustStash {
if err := self.c.Git().Stash.Pop(0); err != nil {
@ -1013,7 +975,6 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
return err
}
self.context().MoveSelectedLine(1)
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
return nil
})
@ -1114,7 +1075,6 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
return err
}
self.context().MoveSelectedLine(1)
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
return nil
})

View file

@ -140,6 +140,7 @@ func (self *RemotesController) enter(remote *models.Remote) error {
remoteBranchesContext.SetSelection(newSelectedLine)
remoteBranchesContext.SetTitleRef(remote.Name)
remoteBranchesContext.SetParentContext(self.Context())
remoteBranchesContext.SetWindowName(self.Context().GetWindowName())
remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix
self.c.PostRefreshUpdate(remoteBranchesContext)
@ -374,6 +375,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
refreshOptions.KeepBranchSelectionIndex = true
refreshOptions.CommitSelection = types.KeepCommitSelectionIndex
}
}
self.c.Refresh(refreshOptions)

View file

@ -1,7 +1,6 @@
package controllers
import (
"errors"
"fmt"
"strings"
"time"
@ -12,7 +11,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type StatusController struct {
@ -33,12 +31,6 @@ func NewStatusController(
func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
Handler: self.openConfig,
Description: self.c.Tr.OpenConfig,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editConfig,
@ -148,38 +140,8 @@ func lazygitTitle() string {
|___/ |___/ `
}
func (self *StatusController) askForConfigFile(action func(file string) error) error {
confPaths := self.c.GetConfig().GetUserConfigPaths()
switch len(confPaths) {
case 0:
return errors.New(self.c.Tr.NoConfigFileFoundErr)
case 1:
return action(confPaths[0])
default:
menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem {
return &types.MenuItem{
Label: path,
OnPress: func() error {
return action(path)
},
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.SelectConfigFile,
Items: menuItems,
})
}
}
func (self *StatusController) openConfig() error {
return self.askForConfigFile(self.c.Helpers().Files.OpenFile)
}
func (self *StatusController) editConfig() error {
return self.askForConfigFile(func(file string) error {
return self.c.Helpers().Files.EditFiles([]string{file})
})
return (&EditConfigAction{c: self.c}).Call()
}
func (self *StatusController) showAllBranchLogs() {

View file

@ -175,7 +175,7 @@ func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions)
},
)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseAndSelectHeadCommit(err)
}
type pushOpts struct {

View file

@ -357,6 +357,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
if didChange && reloadErr == nil {
gui.c.Log.Info("User config changed - reloading")
reloadErr = gui.onUserConfigLoaded()
gui.reloadSidePanels()
if err := gui.resetKeybindings(); err != nil {
return err
}
@ -515,8 +516,10 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC
configsThatDontAutoReload := []string{
"Git.AutoFetch",
"Git.AutoRefresh",
"Git.AutoDetectExternalChanges",
"Refresher.RefreshInterval",
"Refresher.FetchInterval",
"Refresher.ExternalChangeCheckInterval",
"Update.Method",
"Update.Days",
}
@ -579,8 +582,9 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
gui.State = state
gui.State.ViewsSetup = false
contextTree := gui.State.Contexts
gui.State.WindowViewNameMap = initialWindowViewNameMap(contextTree)
// The repo we're switching to may have a per-repo config with a different
// side panel layout, so re-apply it to this repo's contexts.
gui.applySidePanelConfig()
// setting this to nil so we don't get stuck based on a popup that was
// previously opened
@ -620,14 +624,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
},
ScreenMode: initialScreenMode,
// TODO: only use contexts from context manager
ContextMgr: NewContextMgr(gui, contextTree),
Contexts: contextTree,
WindowViewNameMap: initialWindowViewNameMap(contextTree),
SearchState: types.NewSearchState(),
ContextMgr: NewContextMgr(gui, contextTree),
Contexts: contextTree,
SearchState: types.NewSearchState(),
}
gui.RepoStateMap[Repo(worktreePath)] = gui.State
gui.applySidePanelConfig()
return initialContext(contextTree, startArgs)
}
@ -658,13 +663,19 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
return manager
}
func initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
result := utils.NewThreadSafeMap[string, string]()
for _, context := range contextTree.Flatten() {
result.Set(context.GetWindowName(), context.GetViewName())
}
// A side panel's window shows its first configured tab by default, which is
// not necessarily the context that won the loop above.
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
result.Set(panel[0], sidePanelViewNames[panel[0]])
}
return result
}
@ -834,45 +845,19 @@ func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest)
}
func (gui *Gui) viewTabMap() map[string][]context.TabView {
result := map[string][]context.TabView{
"branches": {
{
Tab: gui.c.Tr.LocalBranchesTitle,
ViewName: "localBranches",
},
{
Tab: gui.c.Tr.RemotesTitle,
ViewName: "remotes",
},
{
Tab: gui.c.Tr.TagsTitle,
ViewName: "tags",
},
},
"commits": {
{
Tab: gui.c.Tr.CommitsTitle,
ViewName: "commits",
},
{
Tab: gui.c.Tr.ReflogCommitsTitle,
ViewName: "reflogCommits",
},
},
"files": {
{
Tab: gui.c.Tr.FilesTitle,
ViewName: "files",
},
context.TabView{
Tab: gui.c.Tr.WorktreesTitle,
ViewName: "worktrees",
},
{
Tab: gui.c.Tr.SubmodulesTitle,
ViewName: "submodules",
},
},
titles := gui.sidePanelTabTitles()
result := map[string][]context.TabView{}
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
if len(panel) < 2 {
// A single-tab panel shows its view's own title, not a tab strip.
continue
}
result[panel[0]] = lo.Map(panel, func(name string, _ int) context.TabView {
return context.TabView{
Tab: titles[name],
ViewName: sidePanelViewNames[name],
}
})
}
return result

View file

@ -50,6 +50,10 @@ func (self *guiCommon) Resume() error {
return self.gui.resume()
}
func (self *guiCommon) PauseBackgroundRefreshes(pause bool) {
self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause)
}
func (self *guiCommon) Context() types.IContextMgr {
return self.gui.State.ContextMgr
}

View file

@ -56,6 +56,18 @@ func (self *GuiDriver) Click(x, y int) {
self.waitTillIdle()
}
// FocusIn simulates the terminal window regaining focus, which is how lazygit
// learns to reload changed config files. Tests use it to exercise the live
// config-reload path.
func (self *GuiDriver) FocusIn() {
self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(true),
0,
)
self.waitTillIdle()
}
// wait until lazygit is idle (i.e. all processing is done) before continuing
func (self *GuiDriver) waitTillIdle() {
<-self.isIdleChan
@ -141,6 +153,12 @@ func (self *GuiDriver) View(viewName string) *gocui.View {
return view
}
// TopViewInWindow returns the frontmost visible view in the given window, i.e.
// the tab that is currently shown when a window holds several tabbed views.
func (self *GuiDriver) TopViewInWindow(windowName string) *gocui.View {
return self.gui.helpers.Window.TopViewInWindow(windowName, false)
}
func (self *GuiDriver) SetCaption(caption string) {
self.gui.setCaption(caption)
self.waitTillIdle()

View file

@ -133,7 +133,12 @@ func (gui *Gui) layout(g *gocui.Gui) error {
}
}
minimumHeight := 9
// When the screen is too short the side panels are squashed, with the
// unfocused ones taking one row each and the focused one taking the rest. The
// more panels there are, the more rows the unfocused ones reserve, so the
// floor below which there's no room left for the focused panel grows with the
// panel count. Keep the historical floor of 9 for the default five panels.
minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4)
minimumWidth := 10
gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
@ -249,6 +254,10 @@ func (gui *Gui) onRepoViewReset() error {
}
}
// The loop above orders views by a fixed list, which doesn't necessarily put
// each panel's first configured tab on top.
gui.moveDefaultTabsToTop()
return nil
}

128
pkg/gui/side_panels.go Normal file
View file

@ -0,0 +1,128 @@
package gui
import (
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls.
// A panel's window name is the name of its first tab, so for a panel's first tab
// this also gives the default view of its window. The keys must match
// config.ValidSidePanelTabs (enforced by a test).
var sidePanelViewNames = map[string]string{
"status": "status",
"files": "files",
"worktrees": "worktrees",
"submodules": "submodules",
"branches": "localBranches",
"remotes": "remotes",
"tags": "tags",
"commits": "commits",
"reflog": "reflogCommits",
"stash": "stash",
}
// sidePanelTabTitles maps each gui.sidePanels name to the title shown on its tab.
func (gui *Gui) sidePanelTabTitles() map[string]string {
tr := gui.c.Tr
return map[string]string{
"status": tr.StatusTitle,
"files": tr.FilesTitle,
"worktrees": tr.WorktreesTitle,
"submodules": tr.SubmodulesTitle,
"branches": tr.LocalBranchesTitle,
"remotes": tr.RemotesTitle,
"tags": tr.TagsTitle,
"commits": tr.CommitsTitle,
"reflog": tr.ReflogCommitsTitle,
"stash": tr.StashTitle,
}
}
// sidePanelContexts maps each gui.sidePanels name to the context it controls.
func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Context {
return map[string]types.Context{
"status": contextTree.Status,
"files": contextTree.Files,
"worktrees": contextTree.Worktrees,
"submodules": contextTree.Submodules,
"branches": contextTree.Branches,
"remotes": contextTree.Remotes,
"tags": contextTree.Tags,
"commits": contextTree.LocalCommits,
"reflog": contextTree.ReflogCommits,
"stash": contextTree.Stash,
}
}
// applySidePanelConfig (re)assigns each side context's window and resets each
// window's default view from the current gui.sidePanels config. It runs against
// the current repo's contexts, so gui.State must already be set. We call it on
// every repo entry (a repo's per-repo config can differ from the previous one's)
// and on a live config reload.
func (gui *Gui) applySidePanelConfig() {
contextTree := gui.State.Contexts
gui.assignSidePanelWindows(contextTree)
gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree)
}
// moveDefaultTabsToTop brings each panel's first configured tab to the top of
// its window, so the configured default tab is the one shown when a panel hasn't
// been focused yet (the view z-order is otherwise set from a fixed list that
// need not match the configured tab order).
func (gui *Gui) moveDefaultTabsToTop() {
contexts := sidePanelContexts(gui.State.Contexts)
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
gui.helpers.Window.MoveToTopOfWindow(contexts[panel[0]])
}
}
// reloadSidePanels re-applies the side panel config to the current repo after a
// live config reload: it reassigns windows and default views, restores each
// panel's default tab, and keeps the focused panel in a consistent state.
func (gui *Gui) reloadSidePanels() {
gui.applySidePanelConfig()
gui.moveDefaultTabsToTop()
// applySidePanelConfig reset every window to show its first configured tab,
// which would leave the focused tab hidden behind its panel's default tab
// (the panel would look unfocused even though its tab is selected). Re-focus
// the current context so its tab stays shown and highlighted. If the new
// config has hidden the focused panel entirely, move focus to the default
// side panel instead.
current := gui.c.Context().Current()
if current.GetKind() != types.SIDE_CONTEXT {
return
}
if lo.Contains(gui.helpers.Window.SideWindows(), current.GetWindowName()) {
gui.c.Context().Activate(current, types.OnFocusOpts{})
} else {
gui.c.Context().Push(gui.defaultSideContext(), types.OnFocusOpts{})
}
}
// assignSidePanelWindows sets each side context's window name from the config so
// that contexts grouped into one panel share a window (the window name being the
// panel's first tab). Side panels the user hasn't listed get their own window
// name; since the layout produces no dimensions for those windows, their views
// stay hidden rather than overlapping a visible panel.
func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) {
contexts := sidePanelContexts(contextTree)
assigned := make(map[string]bool, len(contexts))
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
windowName := panel[0]
for _, name := range panel {
contexts[name].SetWindowName(windowName)
assigned[name] = true
}
}
for name, ctx := range contexts {
if !assigned[name] {
ctx.SetWindowName(name)
}
}
}

View file

@ -0,0 +1,30 @@
package gui
import (
"sort"
"testing"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func sortedKeys[V any](m map[string]V) []string {
keys := lo.Keys(m)
sort.Strings(keys)
return keys
}
// The three lookups that translate gui.sidePanels names into views, titles, and
// contexts must each cover exactly the set of valid names, or a config that uses
// a name missing from one of them would hit a nil lookup at runtime.
func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) {
want := lo.Uniq(config.ValidSidePanelTabs)
sort.Strings(want)
gui := NewDummyGui()
assert.Equal(t, want, sortedKeys(sidePanelViewNames))
assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles()))
assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree())))
}

View file

@ -59,6 +59,10 @@ type IGuiCommon interface {
Suspend() error
Resume() error
// Pause or resume the background routines. Calls nest, so every pause must be balanced
// by a resume.
PauseBackgroundRefreshes(pause bool)
Context() IContextMgr
ContextForKey(key ContextKey) Context

View file

@ -33,6 +33,28 @@ const (
BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
)
// CommitSelectionBehavior controls which local commit is selected after the
// commits list is reloaded by a refresh.
type CommitSelectionBehavior int
const (
// Keep the same commit selected by hash (and the same range, when
// range-selecting), restoring it at its new position if it moved. This is
// the right default whenever the list reloads underneath a selection the
// user hasn't deliberately changed.
KeepCommitSelectionByHash CommitSelectionBehavior = iota
// Leave the selection index untouched, because the caller set it itself
// before refreshing. Used when jumping to the top of the list after a
// checkout, and when following a commit that was just moved up or down.
KeepCommitSelectionIndex
// Select the HEAD commit. Used by operations that create a new commit at
// HEAD (committing, merging, pulling with a merge); the by-hash behavior
// can't restore a commit that didn't exist before the refresh.
SelectHeadCommit
)
type RefreshOptions struct {
Then func()
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
@ -44,4 +66,16 @@ type RefreshOptions struct {
// keeps the selection index the same. Useful after checking out a detached
// head, and selecting index 0.
KeepBranchSelectionIndex bool
// Controls which local commit is selected after the refresh. Defaults to
// KeepCommitSelectionByHash.
CommitSelection CommitSelectionBehavior
// When true, this refresh was initiated by a background routine rather than
// by a user action. We use it to keep background `git status` calls from
// taking optional git locks, so they don't contend for index.lock with git
// commands the user runs in a terminal. The cost is that such a status won't
// persist git's refreshed stat-cache, which is the right trade-off for
// unattended work; foreground refreshes leave this false so they do persist.
Background bool
}

View file

@ -9,7 +9,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
"golang.org/x/exp/slices"
)
type viewNameMapping struct {
@ -182,10 +181,12 @@ func (gui *Gui) configureViewProperties() {
gui.Views.Stash.Title = gui.c.Tr.StashTitle
gui.Views.Commits.Title = gui.c.Tr.CommitsTitle
gui.Views.ReflogCommits.Title = gui.c.Tr.ReflogCommitsTitle
gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles
gui.Views.Branches.Title = gui.c.Tr.BranchesTitle
gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle
gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle
gui.Views.Tags.Title = gui.c.Tr.TagsTitle
gui.Views.Files.Title = gui.c.Tr.FilesTitle
gui.Views.PatchBuilding.Title = gui.c.Tr.Patch
@ -210,66 +211,64 @@ func (gui *Gui) configureViewProperties() {
gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage
gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth
if gui.c.UserConfig().Gui.ShowPanelJumps {
keyToTitlePrefix := func(binding config.Keybinding) string {
if len(binding) == 0 {
return ""
}
return fmt.Sprintf("[%s]", binding[0])
keyToTitlePrefix := func(binding config.Keybinding) string {
if len(binding) == 0 {
return ""
}
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string {
return keyToTitlePrefix(binding)
return fmt.Sprintf("[%s]", binding[0])
}
// The views that make up each side panel, in panel order. The whole group
// shares the panel's jump label.
panelViewGroups := lo.Map(gui.c.UserConfig().Gui.SidePanels, func(panel config.SidePanel, _ int) []*gocui.View {
return lo.Map(panel, func(name string, _ int) *gocui.View {
view, _ := gui.g.View(sidePanelViewNames[name])
return view
})
})
gui.Views.Status.TitlePrefix = jumpLabels[0]
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
jumpLabelForPanel := func(panelIndex int) string {
if !gui.c.UserConfig().Gui.ShowPanelJumps || panelIndex >= len(jumpBindings) {
return ""
}
return keyToTitlePrefix(jumpBindings[panelIndex])
}
gui.Views.Files.TitlePrefix = jumpLabels[1]
gui.Views.Worktrees.TitlePrefix = jumpLabels[1]
gui.Views.Submodules.TitlePrefix = jumpLabels[1]
gui.Views.Branches.TitlePrefix = jumpLabels[2]
gui.Views.Remotes.TitlePrefix = jumpLabels[2]
gui.Views.Tags.TitlePrefix = jumpLabels[2]
gui.Views.Commits.TitlePrefix = jumpLabels[3]
gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3]
gui.Views.Stash.TitlePrefix = jumpLabels[4]
for panelIndex, views := range panelViewGroups {
prefix := jumpLabelForPanel(panelIndex)
for _, view := range views {
view.TitlePrefix = prefix
}
}
if gui.c.UserConfig().Gui.ShowPanelJumps {
gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
} else {
gui.Views.Status.TitlePrefix = ""
gui.Views.Files.TitlePrefix = ""
gui.Views.Worktrees.TitlePrefix = ""
gui.Views.Submodules.TitlePrefix = ""
gui.Views.Branches.TitlePrefix = ""
gui.Views.Remotes.TitlePrefix = ""
gui.Views.Tags.TitlePrefix = ""
gui.Views.Commits.TitlePrefix = ""
gui.Views.ReflogCommits.TitlePrefix = ""
gui.Views.Stash.TitlePrefix = ""
gui.Views.Main.TitlePrefix = ""
}
for _, view := range gui.g.Views() {
// if the view is in our mapping, we'll set the tabs and the tab index
for _, values := range gui.viewTabMap() {
index := slices.IndexFunc(values, func(tabContext context.TabView) bool {
return tabContext.ViewName == view.Name()
})
if index != -1 {
view.Tabs = lo.Map(values, func(tabContext context.TabView, _ int) string {
return tabContext.Tab
})
view.TabIndex = index
}
// Index the tab strips by view so we can both set them on views that are
// part of a multi-tab panel and clear them on views that no longer are
// (which matters when the config is reloaded and a tab becomes a standalone
// panel).
type viewTabs struct {
tabs []string
index int
}
tabsByView := map[string]viewTabs{}
for _, values := range gui.viewTabMap() {
labels := lo.Map(values, func(tabContext context.TabView, _ int) string {
return tabContext.Tab
})
for index, tabContext := range values {
tabsByView[tabContext.ViewName] = viewTabs{tabs: labels, index: index}
}
}
for _, view := range gui.g.Views() {
vt := tabsByView[view.Name()]
view.Tabs = vt.tabs
view.TabIndex = vt.index
}
}

View file

@ -101,6 +101,10 @@ type TranslationSet struct {
MergeConflictPressEnterToResolve string
MergeConflictKeepFile string
MergeConflictDeleteFile string
MergeConflictTakeCurrentCommit string
MergeConflictTakeIncomingCommit string
SubmoduleMergeConflictDescription string
StageConflictsRangeDisabled string
Checkout string
CheckoutTooltip string
CantCheckoutBranchWhilePulling string
@ -231,7 +235,6 @@ type TranslationSet struct {
StashChanges string
RenameStash string
RenameStashPrompt string
OpenConfig string
EditConfig string
ForcePush string
ForcePushPrompt string
@ -1028,6 +1031,8 @@ type Actions struct {
StageAllFiles string
ResolveConflictByKeepingFile string
ResolveConflictByDeletingFile string
TakeCurrentSubmoduleCommit string
TakeIncomingSubmoduleCommit string
NotEnoughContextToStage string
NotEnoughContextToDiscard string
NotEnoughContextToRemoveLines string
@ -1201,6 +1206,10 @@ func EnglishTranslationSet() *TranslationSet {
MergeConflictPressEnterToResolve: "Press %s to resolve.",
MergeConflictKeepFile: "Keep file",
MergeConflictDeleteFile: "Delete file",
MergeConflictTakeCurrentCommit: "Take current commit",
MergeConflictTakeIncomingCommit: "Take incoming commit",
SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.",
StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.",
Checkout: "Checkout",
CheckoutTooltip: "Checkout selected item.",
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",
@ -1357,7 +1366,6 @@ func EnglishTranslationSet() *TranslationSet {
StashChanges: "Stash changes",
RenameStash: "Rename stash",
RenameStashPrompt: "Rename stash: {{.stashName}}",
OpenConfig: "Open config file",
EditConfig: "Edit config file",
ForcePush: "Force push",
ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
@ -2116,6 +2124,8 @@ func EnglishTranslationSet() *TranslationSet {
StageAllFiles: "Stage all files",
ResolveConflictByKeepingFile: "Resolve by keeping file",
ResolveConflictByDeletingFile: "Resolve by deleting file",
TakeCurrentSubmoduleCommit: "Resolve submodule conflict by taking current commit",
TakeIncomingSubmoduleCommit: "Resolve submodule conflict by taking incoming commit",
NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.",
NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.",
NotEnoughContextToRemoveLines: "Removing lines from a commit is not possible with a diff context size of 0. Increase the context using '%s'.",

View file

@ -2,21 +2,21 @@
The pkg/integration package is for integration testing: that is, actually running a real lazygit session and having a robot pretend to be a human user and then making assertions that everything works as expected.
TL;DR: integration tests live in pkg/integration/tests. Run integration tests with:
TL;DR: integration tests live in pkg/integration/tests, and we run them through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`. Run the whole suite headlessly with:
```sh
go run cmd/integration_test/main.go tui
just e2e
```
or
or open a terminal UI to browse and run individual tests with:
```sh
go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...]
just e2e-tui
```
## Writing tests
The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `go generate ./...` at the root of the Lazygit repo.
The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `just generate` at the root of the Lazygit repo.
Each test has two important steps: the setup step and the run step.
@ -38,19 +38,18 @@ The run step has two arguments passed in:
## Running tests
There are three ways to invoke a test:
We drive the integration tests through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`, so you'll want `just` installed to run them as described here. (The recipes are thin wrappers, so if you can't install `just`, the underlying commands are right there in the `justfile`.)
1. go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...]
2. go run cmd/integration_test/main.go tui
3. go test pkg/integration/clients/*.go
- `just e2e` — run the whole suite headlessly, with no visible UI. This is what CI does, and the fastest way to run everything.
- `just e2e <name>` — run a single test headlessly, e.g. `just e2e commit/new_branch`; the fastest way to run one test. You can pass several names at once, or a full file path like `pkg/integration/tests/commit/new_branch.go`.
- `just e2e-cli [--slow|--sandbox|--debug] <name>` — run a single test in a *visible* lazygit UI, so you can watch it (see slow mode below, and sandbox mode and debugging in the following sections).
- `just e2e-tui` — open a terminal UI for browsing and running tests; the easiest way to find and run a test without having to type its name.
The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests.
The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far.
The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output.
The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is `commit/new_branch`.
The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`.
zsh users can get tab-completion of these test names — `just e2e sub<Tab>` expands to `submodule/…` — by sourcing `scripts/just_e2e_completion.zsh` from their `.zshrc`; see the comment at the top of that file for details.
You can pass the INPUT_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses or mouse clicks, 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.
To watch a test run at a realistic speed, pass `--slow` to `just e2e-cli`; it sets a pre-set delay between keypresses and mouse clicks. For finer control, set the `INPUT_DELAY` env var to a number of milliseconds instead, e.g. `INPUT_DELAY=200 just e2e-cli commit/new_branch`. In the TUI you can press 't' to run a test in slow mode.
The resultant repo will be stored in `test/_results`, so if you're not sure what went wrong you can go there and inspect the repo.
@ -67,8 +66,8 @@ The test will run in a VSCode terminal:
Debugging an integration test is possible in two ways:
1. Use the -debug option of the integration test runner's "cli" command, e.g. `go run cmd/integration_test/main.go cli -debug tag/reset.go`
2. Select a test in the "tui" runner and hit "d" to debug it.
1. Pass `--debug` to `just e2e-cli`, e.g. `just e2e-cli --debug tag/reset`.
2. Select a test in `just e2e-tui` and hit "d" to debug it.
In both cases the test runner will print to the console that it is waiting for a debugger to attach, so now you need to tell your debugger to attach to a running process with the name "test_lazygit". If you are using Visual Studio Code, an easy way to do that is to use the "Attach to integration test runner" debug configuration. The test runner will resume automatically when it detects that a debugger was attached. Don't forget to set a breakpoint in the code that you want to step through, otherwise the test will just finish (i.e. it doesn't stop in the debugger automatically).
@ -76,7 +75,7 @@ In both cases the test runner will print to the console that it is waiting for a
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 the --sandbox argument.
To run a test in sandbox mode, press 's' on a test in `just e2e-tui`, or pass `--sandbox` to `just e2e-cli`, e.g. `just e2e-cli --sandbox conflicts/resolve_multiple_files`.
## Tips for writing tests

View file

@ -56,6 +56,14 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) {
self.press(key[0])
}
// FocusIn simulates the terminal window regaining focus, which causes lazygit
// to reload any config files that changed while it was in the background.
func (self *TestDriver) FocusIn() {
self.SetCaption("Focusing window")
self.gui.FocusIn()
self.Wait(self.inputDelay)
}
func (self *TestDriver) typeContent(content string) {
for _, char := range content {
self.pressFast(string(char))

View file

@ -34,6 +34,9 @@ func (self *fakeGuiDriver) Click(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) FocusIn() {
}
func (self *fakeGuiDriver) Keys() config.KeybindingConfig {
return config.KeybindingConfig{}
}
@ -72,6 +75,10 @@ func (self *fakeGuiDriver) View(viewName string) *gocui.View {
return nil
}
func (self *fakeGuiDriver) TopViewInWindow(windowName string) *gocui.View {
return nil
}
func (self *fakeGuiDriver) SetCaption(string) {
}

View file

@ -408,6 +408,28 @@ func (self *ViewDriver) IsFocused() *ViewDriver {
return self
}
// asserts that the view is the one currently shown in its window, i.e. it's the
// active tab of its panel (drawn in front of the window's other tabs). Unlike
// IsFocused, this is about what's displayed rather than which view has keyboard
// focus; the two can disagree, e.g. if a config reload reshuffles the tabs.
func (self *ViewDriver) IsActiveTab() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
expected := self.getView().Name()
context := self.t.gui.ContextForView(expected)
if context == nil {
return false, fmt.Sprintf("%s: Could not find context for view, so can't determine its window", expected)
}
topView := self.t.gui.TopViewInWindow(context.GetWindowName())
actual := ""
if topView != nil {
actual = topView.Name()
}
return actual == expected, fmt.Sprintf("%s: Expected view to be the active tab of its window, but it was %s", expected, actual)
})
return self
}
func (self *ViewDriver) Press(key config.Keybinding) *ViewDriver {
self.IsFocused()

View file

@ -73,25 +73,11 @@ var CherryPickCommitThatBecomesEmpty = NewIntegrationTest(NewIntegrationTestArgs
// Cherry-picked commit is empty
t.Views().Main().Content(DoesNotContain("diff --git"))
} else {
// Older git versions drop the commit that became empty
t.Views().Commits().
// We have a bug with how the selection is updated in this case; normally you would
// expect the "two changes in one commit" commit to be selected because it was
// selected before pasting, and we try to maintain that selection. This is broken
// for two reasons:
// 1. We increment the selected line index after pasting by the number of pasted
// commits; this is wrong because we skipped the commit that became empty. So
// according to this bug, the "base" commit should be selected.
// 2. We only update the selected line index after pasting if the currently selected
// commit is not a rebase TODO commit, on the assumption that if it is, we are in a
// rebase and the cherry-picked commits end up below the selection. In this case,
// however, we still think we are cherry-picking because the final refresh after the
// CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet;
// so the "unrelated change" still has a "pick" action.
//
// Since this only happens for older git versions, we don't bother fixing it.
Lines(
Contains("unrelated change").IsSelected(),
Contains("two changes in one commit"),
Contains("unrelated change"),
Contains("two changes in one commit").IsSelected(),
Contains("base"),
)
}

View file

@ -78,11 +78,11 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
TopLines(
Contains("second-change-branch unrelated change").IsSelected(),
Contains("second-change-branch unrelated change"),
Contains("second change"),
Contains("first change"),
Contains("first change").IsSelected(),
).
SelectNextItem().
SelectPreviousItem().
Tap(func() {
// because we picked 'Second change' when resolving the conflict,
// we now see this commit as having replaced First Change with Second Change,

View file

@ -69,23 +69,8 @@ var CherryPickConflictsEmptyCommitAfterResolving = NewIntegrationTest(NewIntegra
t.Views().Commits().
Focus().
TopLines(
// We have a bug with how the selection is updated in this case; normally you would
// expect the "first change" commit to be selected because it was selected before
// pasting, and we try to maintain that selection. This is broken for two reasons:
// 1. We increment the selected line index after pasting by the number of pasted
// commits; this is wrong because we skipped the commit that became empty. So
// according to this bug, the "original" commit should be selected.
// 2. We only update the selected line index after pasting if the currently selected
// commit is not a rebase TODO commit, on the assumption that if it is, we are in a
// rebase and the cherry-picked commits end up below the selection. In this case,
// however, we still think we are cherry-picking because the final refresh after the
// CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet;
// so the "second-change-branch unrelated change" still has a "pick" action.
//
// We don't bother fixing it for now because it's a pretty niche case, and the
// nature of the problem is only cosmetic.
Contains("second-change-branch unrelated change").IsSelected(),
Contains("first change"),
Contains("second-change-branch unrelated change"),
Contains("first change").IsSelected(),
Contains("original"),
)
},

View file

@ -0,0 +1,46 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepSelectedCommitAfterExternalCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep the same commit selected after an external commit is created",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "first content")
shell.Commit("first commit")
shell.UpdateFile("file", "second content")
shell.GitAddAll()
shell.Commit("second commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("second commit"),
Contains("first commit"),
).
NavigateToLine(Contains("first commit"))
t.Views().Main().Content(Contains("+first content"))
t.GlobalPress(keys.Universal.ExecuteShellCommand)
t.ExpectPopup().Prompt().
Title(Equals("Shell command:")).
Type("git commit --allow-empty -m 'external commit'").
Confirm()
t.Views().Commits().
Lines(
Contains("external commit"),
Contains("second commit"),
Contains("first commit").IsSelected(),
)
t.Views().Main().Content(Contains("+first content"))
},
})

View file

@ -0,0 +1,58 @@
package config
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SidePanelsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A per-repo config can set the side panel layout, and switching repos re-applies each repo's own layout",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
otherRepo, _ := filepath.Abs("../other")
cfg.GetAppState().RecentRepos = []string{otherRepo}
},
SetupRepo: func(shell *Shell) {
shell.CloneNonBare("other")
// The other repo swaps the branches and commits panels.
shell.CreateFile("../other/.git/lazygit.yml", `
gui:
sidePanels:
- [status]
- [files, worktrees, submodules]
- [commits, reflog]
- [branches, remotes, tags]
- [stash]`)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// This repo uses the default layout, so the third panel is branches.
t.GlobalPress(keys.Universal.JumpToBlock[2])
t.Views().Branches().IsFocused()
// Switch to the other repo, whose per-repo config swaps branches and commits.
t.GlobalPress(keys.Universal.OpenRecentRepos)
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).
Lines(
Contains("other").IsSelected(),
Contains("Cancel"),
).Confirm()
t.Views().Status().Content(Contains("other → master"))
// Now the third panel is commits.
t.GlobalPress(keys.Universal.JumpToBlock[2])
t.Views().Commits().IsFocused()
// Switch back to the first repo; its default layout is intact even though
// its contexts were built before we visited the other repo.
t.GlobalPress(keys.Universal.JumpToBlock[1])
t.Views().Files().IsFocused()
t.GlobalPress(keys.Universal.OpenRecentRepos)
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).Confirm()
t.GlobalPress(keys.Universal.JumpToBlock[2])
t.Views().Branches().IsFocused()
},
})

View file

@ -0,0 +1,56 @@
package conflicts
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
},
SetupRepo: func(shell *Shell) {
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`)
// theirs: delete foo, modify bar
shell.RunShellCommand(`git checkout -b theirs`)
shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`)
// ours: modify foo, delete bar
shell.RunShellCommand(`git checkout base && git checkout -b ours`)
shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`)
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("DU bar"),
Contains("UD foo"),
).
// Pressing space on a single non-textual conflict opens the
// resolution menu rather than trying to stage it.
NavigateToLine(Contains("bar")).
PressPrimaryAction().
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel()
}).
// Staging is disabled for a range selection that includes a conflict.
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("foo")).
PressPrimaryAction().
Tap(func() {
t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts"))
}).
// Entering a range selection is disabled too, with the usual toast.
Press(keys.Universal.GoInto).
Tap(func() {
t.ExpectToast(Contains("does not support range selection"))
})
},
})

View file

@ -0,0 +1,82 @@
package submodule
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Resolve a submodule conflict (both sides moved the gitlink) by picking one side's commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
shell.GitAddAll()
shell.Commit("add submodule")
sub := "my_submodule_path"
// Two diverging commits in the submodule, so the gitlink can't be
// fast-forwarded and the merge genuinely conflicts.
shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "left"})
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "left"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "right", "HEAD~1"})
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "right"})
// "ours" points the submodule at left, "theirs" at right.
shell.RunCommand([]string{"git", "checkout", "-b", "ours"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"})
shell.RunCommand([]string{"git", "add", sub})
shell.Commit("ours")
shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "right"})
shell.RunCommand([]string{"git", "add", sub})
shell.Commit("theirs")
shell.RunCommand([]string{"git", "checkout", "ours"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"})
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Lines(
Contains("UU my_submodule_path (submodule)").IsSelected(),
).
Tap(func() {
// The main view explains the conflict and shows each side's
// commits as separate "current" and "incoming" logs.
t.Views().Main().Content(
Contains("Conflict: the submodule").
Contains("Current changes:").Contains("left").
Contains("Incoming changes:").Contains("right"),
)
}).
// Enter opens the resolution menu instead of entering the submodule.
// The two candidate commits are shown with their summaries.
Press(keys.Universal.GoInto).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Merge conflicts")).
Select(Contains("Take current commit").Contains("left")).
Select(Contains("Take incoming commit").Contains("right")).
Cancel()
}).
// Space opens the same menu; take the incoming commit to resolve.
PressPrimaryAction().
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Merge conflicts")).
Select(Contains("Take incoming commit")).
Confirm()
}).
Lines(
Contains("M my_submodule_path (submodule)").IsSelected(),
)
},
})

View file

@ -0,0 +1,63 @@
package submodule
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResolveConflictRewoundSide = NewIntegrationTest(NewIntegrationTestArgs{
Description: "When a side of a submodule conflict added no commits of its own (it was rewound), the main view shows the commit it points at instead of an empty log",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CloneIntoSubmodule("sub_name", "sub_path")
shell.GitAddAll()
shell.Commit("add submodule")
sub := "sub_path"
// Mark the submodule's initial commit, then advance it; the merge base
// will point the submodule here.
shell.RunCommand([]string{"git", "-C", sub, "branch", "initial"})
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s1"})
shell.RunCommand([]string{"git", "add", sub})
shell.Commit("base at s1")
// "ours" rewinds the submodule to its initial commit (so it has no
// commits of its own relative to "theirs").
shell.RunCommand([]string{"git", "checkout", "-b", "ours"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"})
shell.RunCommand([]string{"git", "add", sub})
shell.Commit("ours rewinds submodule")
// "theirs" advances the submodule with a further commit.
shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "master"})
shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s2"})
shell.RunCommand([]string{"git", "add", sub})
shell.Commit("theirs advances submodule")
shell.RunCommand([]string{"git", "checkout", "ours"})
shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"})
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Lines(
Contains("UU sub_path (submodule)").IsSelected(),
).
Tap(func() {
// "ours" has no commits of its own, so its section falls back to
// the commit it points at; "theirs" lists the commits it added.
t.Views().Main().Content(
Contains("Current changes:").Contains("first commit").
Contains("Incoming changes:").Contains("s1").Contains("s2"),
)
})
},
})

View file

@ -29,7 +29,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("four"),
Contains("four").IsSelected(),
Contains("one"),
)
@ -43,7 +43,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Lines(
Contains("Merge branch 'master' of ../origin"),
Contains("Merge branch 'master' of ../origin").IsSelected(),
Contains("three"),
Contains("two"),
Contains("four"),

View file

@ -139,6 +139,7 @@ var tests = []*components.IntegrationTest{
commit.Highlight,
commit.History,
commit.HistoryComplex,
commit.KeepSelectedCommitAfterExternalCommit,
commit.NewBranch,
commit.PasteCommitMessage,
commit.PasteCommitMessageOverExisting,
@ -161,6 +162,7 @@ var tests = []*components.IntegrationTest{
config.CustomCommandsInPerRepoConfig,
config.NegativeRefspec,
config.RemoteNamedStar,
config.SidePanelsInPerRepoConfig,
conflicts.Filter,
conflicts.MergeFileBoth,
conflicts.MergeFileCurrent,
@ -170,6 +172,7 @@ var tests = []*components.IntegrationTest{
conflicts.ResolveNoAutoStage,
conflicts.ResolveNonTextualConflicts,
conflicts.ResolveWithoutTrailingLf,
conflicts.SpaceOnNonTextualConflict,
conflicts.UndoChooseHunk,
custom_commands.AccessCommitProperties,
custom_commands.BasicCommand,
@ -427,6 +430,8 @@ var tests = []*components.IntegrationTest{
submodule.RemoveNested,
submodule.Reset,
submodule.ResetFolder,
submodule.ResolveConflict,
submodule.ResolveConflictRewoundSide,
submodule.Stage,
submodule.StageAllWithDirtySubmodule,
submodule.StageDirtyOnly,
@ -473,11 +478,15 @@ var tests = []*components.IntegrationTest{
ui.Accordion,
ui.DisableSwitchTabWithPanelJumpKeys,
ui.EmptyMenu,
ui.HideSidePanel,
ui.KeybindingSuggestionsDontCrashOnDisabledBindings,
ui.KeybindingSuggestionsWhenSwitchingRepos,
ui.ModeSpecificKeybindingSuggestions,
ui.OpenLinkFailure,
ui.PromoteTabToSidePanel,
ui.RangeSelect,
ui.ReloadSidePanels,
ui.ReorderSidePanels,
ui.SwitchTabFromMenu,
ui.SwitchTabWithPanelJumpKeys,
undo.UndoCheckoutAndDrop,

View file

@ -0,0 +1,33 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var HideSidePanel = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hide a side panel by omitting it from gui.sidePanels",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// No stash panel.
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Commits is now the last panel; cycling forward from it wraps around to
// the status panel, skipping the hidden stash panel entirely.
t.Views().Files().IsFocused().
Press(keys.Universal.JumpToBlock[3])
t.Views().Commits().IsFocused().
Press(keys.Universal.NextBlock)
t.Views().Status().IsFocused()
},
})

View file

@ -0,0 +1,40 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PromoteTabToSidePanel = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Promote the worktrees tab to its own top-level side panel via gui.sidePanels",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// Worktrees is pulled out of the files panel into its own panel.
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "submodules"},
{"worktrees"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
{"stash"},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Worktrees is now its own panel in the third position, reachable by its
// jump key rather than as a tab of the files panel.
t.Views().Files().IsFocused().
Press(keys.Universal.JumpToBlock[2])
t.Views().Worktrees().IsFocused().
Press(keys.Universal.JumpToBlock[1])
// The files panel's tabs are now just files and submodules, so cycling
// tabs from files goes straight to submodules.
t.Views().Files().IsFocused().
Press(keys.Universal.NextTab)
t.Views().Submodules().IsFocused()
},
})

View file

@ -0,0 +1,51 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ReloadSidePanels = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Editing the side panel config and refocusing the window re-applies the layout live, keeping the focused panel focused",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
// Start with worktrees promoted to its own panel.
shell.CreateFile(".git/lazygit.yml", `
gui:
sidePanels:
- [status]
- [files, submodules]
- [worktrees]
- [branches, remotes, tags]
- [commits, reflog]
- [stash]`)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Worktrees is its own panel in the third position.
t.Views().Files().IsFocused().
Press(keys.Universal.JumpToBlock[2])
t.Views().Worktrees().IsFocused()
// Demote worktrees back into the files panel, then refocus the window to
// trigger a live reload of the changed config.
t.Shell().UpdateFile(".git/lazygit.yml", `
gui:
sidePanels:
- [status]
- [files, worktrees, submodules]
- [branches, remotes, tags]
- [commits, reflog]
- [stash]`)
t.FocusIn()
// Worktrees is now a tab of the files panel. It stays focused, and is shown
// in front rather than being hidden behind the files tab (which would leave
// the panel looking unfocused).
t.Views().Worktrees().IsActiveTab().IsFocused().
Press(keys.Universal.PrevTab)
t.Views().Files().IsActiveTab().IsFocused()
},
})

View file

@ -0,0 +1,33 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ReorderSidePanels = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reorder the side panels with gui.sidePanels, swapping the branches and commits panels",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"commits", "reflog"},
{"branches", "remotes", "tags"},
{"stash"},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// The third panel is now commits and the fourth is branches (the reverse
// of the default order), so their jump keys are swapped.
t.Views().Files().IsFocused().
Press(keys.Universal.JumpToBlock[2])
t.Views().Commits().IsFocused().
Press(keys.Universal.JumpToBlock[3])
t.Views().Branches().IsFocused()
},
})

View file

@ -24,6 +24,9 @@ type IntegrationTest interface {
type GuiDriver interface {
PressKey(string)
Click(int, int)
// Simulate the terminal window regaining focus (which triggers a reload of
// changed config files)
FocusIn()
Keys() config.KeybindingConfig
CurrentContext() types.Context
ContextForView(viewName string) types.Context
@ -41,6 +44,8 @@ type GuiDriver interface {
// e.g. when we're showing both staged and unstaged changes
SecondaryView() *gocui.View
View(viewName string) *gocui.View
// the frontmost visible view in the given window, i.e. the currently shown tab
TopViewInWindow(windowName string) *gocui.View
SetCaption(caption string)
SetCaptionPrefix(prefix string)
// Pop the next toast that was displayed; returns nil if there was none

View file

@ -11,8 +11,8 @@ import (
)
func tailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
var lastModified int64 = 0
var lastOffset int64 = 0
var lastModified int64
var lastOffset int64
for {
stat, err := os.Stat(logFilePath)
if err != nil {

View file

@ -358,6 +358,11 @@
"description": "If true, periodically refresh files and submodules",
"default": true
},
"autoDetectExternalChanges": {
"type": "boolean",
"description": "If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.",
"default": true
},
"autoForwardBranches": {
"type": "string",
"enum": [
@ -585,6 +590,35 @@
"description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.",
"default": 2
},
"sidePanels": {
"items": {
"$ref": "#/$defs/SidePanel"
},
"type": "array",
"description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.",
"default": [
[
"status"
],
[
"files",
"worktrees",
"submodules"
],
[
"branches",
"remotes",
"tags"
],
[
"commits",
"reflog"
],
[
"stash"
]
]
},
"mainPanelSplitMode": {
"type": "string",
"enum": [
@ -3354,6 +3388,20 @@
}
],
"default": "\u003cctrl+t\u003e"
},
"editConfig": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "\u003calt+shift+c\u003e"
}
},
"additionalProperties": false,
@ -3543,21 +3591,45 @@
"properties": {
"refreshInterval": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": 0,
"description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.",
"default": 10
},
"fetchInterval": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": 0,
"description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.",
"default": 60
},
"externalChangeCheckInterval": {
"type": "integer",
"exclusiveMinimum": 0,
"description": "Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).\nDetection can be disabled via option 'git.autoDetectExternalChanges'.",
"default": 2
}
},
"additionalProperties": false,
"type": "object",
"description": "Background refreshes"
},
"SidePanel": {
"items": {
"type": "string",
"enum": [
"status",
"files",
"worktrees",
"submodules",
"branches",
"remotes",
"tags",
"commits",
"reflog",
"stash"
]
},
"type": "array"
},
"SpinnerConfig": {
"properties": {
"frames": {

View file

@ -5,15 +5,13 @@
set -e
git diff --quiet || {
echo "Error: there are unstaged changes. Please stage or stash them before running this script."
exit 1
}
just test
just lint
status_before_generate=$(git status --porcelain=v1)
just generate
git diff --quiet || {
status_after_generate=$(git status --porcelain=v1)
if [[ "$status_after_generate" != "$status_before_generate" ]]; then
echo "Error: auto-generated files not up to date."
exit 1
}
fi

View file

@ -0,0 +1,56 @@
# Zsh completion for the `e2e` and `e2e-cli` recipes in lazygit's justfile.
#
# These recipes take integration-test names (e.g. submodule/reset). This makes
# `just e2e <Tab>` complete them from pkg/integration/tests/. To enable it, add
# the following to your ~/.zshrc, *after* the line that runs `compinit`:
#
# source /path/to/lazygit/scripts/just_e2e_completion.zsh
#
# It is a no-op when `just` isn't installed, and only kicks in inside a project
# that has a justfile and a pkg/integration/tests/ directory, so it is harmless
# to source unconditionally.
(( $+commands[just] )) || return 0
# just's own completion is clap-dynamic and has no hook for completing a
# recipe's arguments, so we wrap it: handle the e2e recipes ourselves and
# delegate everything else (recipe names, flags, ...) to just's completer.
source <(JUST_COMPLETE=zsh just) # defines _clap_dynamic_completer_just
_just_lazygit_e2e() {
if (( CURRENT > 2 )); then
case ${words[2]} in
e2e | e2e-cli)
# Find the justfile's directory, then complete the integration
# tests under pkg/integration/tests/ relative to it.
local dir=$PWD testdir=
while [[ $dir != / ]]; do
if [[ -e $dir/justfile || -e $dir/.justfile || -e $dir/Justfile ]]; then
testdir=$dir/pkg/integration/tests
break
fi
dir=${dir:h}
done
if [[ -d $testdir ]]; then
# A test's name is its path under pkg/integration/tests/ without
# the .go extension, e.g. submodule/reset. Build that list, then
# let _multi_parts complete it one "/"-separated segment at a
# time, so an empty <Tab> offers only categories.
local -a tests
tests=($testdir/**/*.go(.N:r)) # strip the .go extension
tests=(${tests#$testdir/}) # make relative to the tests dir
tests=(${(M)tests:#*/*}) # keep category/name (drop top-level helpers)
tests=(${tests:#shared/*}) # drop the cross-directory shared package
tests=(${tests:#*/shared}) # drop per-category shared.go helpers
local expl
_wanted tests expl 'integration test' _multi_parts / tests
return
fi
;;
esac
fi
_clap_dynamic_completer_just "$@"
}
compdef _just_lazygit_e2e just # bind last so this wins over the default

View file

@ -20,3 +20,4 @@ git:
# TODO: add tests which explicitly test auto-refresh functionality
autoRefresh: false
autoFetch: false
autoDetectExternalChanges: false