diff --git a/docs/Config.md b/docs/Config.md index 4bd589fe095..f9dadd8eaed 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -619,7 +619,7 @@ keybinding: viewResetOptions: D fetch: f toggleTreeView: '`' - openMergeTool: M + openMergeOptions: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 01a935102f7..e42ebee1c51 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -153,7 +153,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -210,7 +210,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Undo | Undo last merge conflict resolution. | | `` e `` | Edit file | Open file in external editor. | | `` o `` | Open file | Open file in default application. | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Return to files panel | | ## Main panel (normal) diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 9215f63dd7a..207b0d39749 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -235,7 +235,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` D `` | リセット | 作業ツリーのリセットオプション(例:作業ツリーの完全破棄)を表示します。 | | `` ` `` | ファイルツリービューを切り替え | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 外部差分ツールを開く(git difftool) | | -| `` M `` | 外部マージツールを開く | `git mergetool`を実行します。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | フェッチ | リモートから変更をフェッチします。 | | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | | `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します | @@ -292,7 +292,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` z `` | 元に戻す | 最後のマージコンフリクト解決を元に戻します。 | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | -| `` M `` | 外部マージツールを開く | `git mergetool`を実行します。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | ファイルパネルに戻る | | ## メインパネル(通常) diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index 96ef98ad077..e0d83760a54 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -152,7 +152,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | 되돌리기 | Undo last merge conflict resolution. | | `` e `` | 파일 편집 | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | -| `` M `` | Git mergetool를 열기 | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 파일 목록으로 돌아가기 | | ## 메인 패널 (Normal) @@ -396,7 +396,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Git mergetool를 열기 | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 71c35fcdfff..9fea316a725 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -78,7 +78,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -218,7 +218,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | | `` e `` | Verander bestand | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Ga terug naar het bestanden paneel | | ## Normaal diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 5f776570922..92b9c63880f 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -193,7 +193,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | -| `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Wróć do panelu plików | | ## Panel główny (zatwierdzanie) @@ -252,7 +252,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` D `` | Reset | Wyświetl opcje resetu dla drzewa roboczego (np. zniszczenie drzewa roboczego). | | `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | -| `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Pobierz | Pobierz zmiany ze zdalnego serwera. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index c6349bb8303..1ae9178c1c2 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -78,7 +78,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Restaurar | Opções de redefinição de exibição para árvore de trabalho (por exemplo, nukando a árvore de trabalho). | | `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | -| `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Buscar | Buscar alterações do controle remoto. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | @@ -278,7 +278,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | -| `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Retornar ao painel de arquivos | | ## Painel principal (patch build) diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 2586a78e4f2..4c46c54793e 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -122,7 +122,7 @@ _Связки клавиш_ | `` z `` | Отменить | Undo last merge conflict resolution. | | `` e `` | Редактировать файл | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | -| `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Вернуться к панели файлов | | ## Главная панель (сборка патчей) @@ -390,7 +390,7 @@ _Связки клавиш_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Получить изменения | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index decb0e6035c..ffc50aedae9 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -216,7 +216,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` D `` | 重置 | 查看工作树的重置选项(例如:清除工作树)。 | | `` ` `` | 切换文件树视图 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 使用外部差异比较工具(git difftool) | | -| `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | 抓取 | 从远程获取变更 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | @@ -305,7 +305,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` z `` | 撤销 | 撤消上次合并冲突解决 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | -| `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 返回文件面板 | | ## 正在暂存 diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index 66fae12b701..2e7e58e7e17 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -97,7 +97,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` z `` | 復原 | Undo last merge conflict resolution. | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | -| `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 返回檔案面板 | | ## 主面板(預存) @@ -347,7 +347,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 開啟外部差異工具 (git difftool) | | -| `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | 擷取 | 同步遠端異動 | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index e6b066d346f..ffa1d99b6d8 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -407,3 +408,46 @@ func (self *WorkingTreeCommands) ResetMixed(ref string) error { return self.cmd.New(cmdArgs).Run() } + +func (self *WorkingTreeCommands) ShowFileAtStage(path string, stage int) (string, error) { + cmdArgs := NewGitCmd("show"). + Arg(fmt.Sprintf(":%d:%s", stage, path)). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} + +func (self *WorkingTreeCommands) ObjectIDAtStage(path string, stage int) (string, error) { + cmdArgs := NewGitCmd("rev-parse"). + Arg(fmt.Sprintf(":%d:%s", stage, path)). + ToArgv() + + output, err := self.cmd.New(cmdArgs).RunWithOutput() + if err != nil { + return "", err + } + + return strings.TrimSpace(output), nil +} + +func (self *WorkingTreeCommands) MergeFileForFiles(strategy string, oursFilepath string, baseFilepath string, theirsFilepath string) (string, error) { + cmdArgs := NewGitCmd("merge-file"). + Arg(strategy). + Arg("--stdout"). + Arg(oursFilepath, baseFilepath, theirsFilepath). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} + +// OIDs mode (Git 2.43+) +func (self *WorkingTreeCommands) MergeFileForObjectIDs(strategy string, oursID string, baseID string, theirsID string) (string, error) { + cmdArgs := NewGitCmd("merge-file"). + Arg(strategy). + Arg("--stdout"). + Arg("--object-id"). + Arg(oursID, baseID, theirsID). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index ba1252b6ef6..dde4b538248 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -272,6 +272,7 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] {[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"}, {[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"}, {[]string{"gui", "windowSize"}, "screenMode"}, + {[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"}, } for _, pathToReplace := range pathsToReplace { diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index faba2e4e848..cc7ffea790d 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -897,7 +897,7 @@ keybinding: toggleStagedAll: a viewResetOptions: D fetch: f - openMergeTool: M + openMergeOptions: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index c30d030aeaf..0323e6de1fb 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -487,7 +487,7 @@ type KeybindingFilesConfig struct { ViewResetOptions string `yaml:"viewResetOptions"` Fetch string `yaml:"fetch"` ToggleTreeView string `yaml:"toggleTreeView"` - OpenMergeTool string `yaml:"openMergeTool"` + OpenMergeOptions string `yaml:"openMergeOptions"` OpenStatusFilter string `yaml:"openStatusFilter"` CopyFileInfoToClipboard string `yaml:"copyFileInfoToClipboard"` CollapseAll string `yaml:"collapseAll"` @@ -950,7 +950,7 @@ func GetDefaultConfig() *UserConfig { ViewResetOptions: "D", Fetch: "f", ToggleTreeView: "`", - OpenMergeTool: "M", + OpenMergeOptions: "M", OpenStatusFilter: "", ConfirmDiscard: "x", CopyFileInfoToClipboard: "y", diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index c0319f5c0c6..d4b847c946d 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -124,8 +124,8 @@ func (gui *Gui) getRandomTip() string { formattedKey(config.Universal.Remove), ), fmt.Sprintf( - "If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open 'git mergetool'", - formattedKey(config.Files.OpenMergeTool), + "If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open merge options", + formattedKey(config.Files.OpenMergeOptions), ), fmt.Sprintf( "To revert a commit, press '%s' on that commit", diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index f3ea245cfc2..2f729a7b50b 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -98,7 +98,7 @@ func (gui *Gui) resetHelpersAndControllers() { Bisect: bisectHelper, Suggestions: suggestionsHelper, Files: helpers.NewFilesHelper(helperCommon), - WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper), + WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper, rebaseHelper), Tags: helpers.NewTagsHelper(helperCommon, commitsHelper, gpgHelper), BranchesHelper: helpers.NewBranchesHelper(helperCommon, worktreeHelper), GPG: helpers.NewGpgHelper(helperCommon), diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index e512cb49821..4a3951c4829 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -178,10 +178,13 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types Description: self.c.Tr.OpenDiffTool, }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeTool), - Handler: self.c.Helpers().WorkingTree.OpenMergeTool, - Description: self.c.Tr.OpenMergeTool, - Tooltip: self.c.Tr.OpenMergeToolTooltip, + Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Handler: self.withItems(self.openMergeConflictMenu), + Description: self.c.Tr.ViewMergeConflictOptions, + Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, + GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)), + OpensMenu: true, + DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.Fetch), @@ -1024,6 +1027,34 @@ func (self *FilesController) createStashMenu() error { }) } +func (self *FilesController) openMergeConflictMenu(nodes []*filetree.FileNode) error { + normalizedNodes := flattenSelectedNodesToFiles(nodes) + + fileNodesWithConflicts := lo.Filter(normalizedNodes, func(node *filetree.FileNode, _ int) bool { + return node.File != nil && node.File.HasInlineMergeConflicts + }) + + filepaths := lo.Map(fileNodesWithConflicts, func(node *filetree.FileNode, _ int) string { + return node.GetPath() + }) + + return self.c.Helpers().WorkingTree.CreateMergeConflictMenu(filepaths) +} + +func (self *FilesController) canOpenMergeConflictMenu(nodes []*filetree.FileNode) *types.DisabledReason { + normalizedNodes := flattenSelectedNodesToFiles(nodes) + + hasFileNodesWithConflicts := lo.SomeBy(normalizedNodes, func(node *filetree.FileNode) bool { + return node.File != nil && node.File.HasInlineMergeConflicts + }) + + if !hasFileNodesWithConflicts { + return &types.DisabledReason{Text: self.c.Tr.NoFilesWithMergeConflicts} + } + + return nil +} + func (self *FilesController) openCopyMenu() error { node := self.context().GetSelected() @@ -1237,6 +1268,38 @@ func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filet return false } +// BFS algorithm for expanding directories into their children, +// and for collecting the unique file nodes +func flattenSelectedNodesToFiles(selectedNodes []*filetree.FileNode) []*filetree.FileNode { + queue := append(make([]*filetree.FileNode, 0, len(selectedNodes)), selectedNodes...) + visited := set.New[string]() + var files []*filetree.FileNode + + for len(queue) > 0 { + // pop node from queue + node := queue[0] + queue = queue[1:] + + nodeID := node.ID() + if visited.Includes(nodeID) { + continue + } + visited.Add(nodeID) + + if node.File != nil { + // unique file node -> collect it + files = append(files, node) + continue + } + + // directory node -> enqueue children + for _, ch := range node.Children { + queue = append(queue, &filetree.FileNode{Node: ch}) + } + } + return files +} + func someNodesHaveUnstagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasUnstagedChanges) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index e7bc74fa904..9e6e362966d 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -3,21 +3,24 @@ package helpers import ( "errors" "fmt" + "os" "regexp" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type WorkingTreeHelper struct { - c *HelperCommon - refHelper *RefsHelper - commitsHelper *CommitsHelper - gpgHelper *GpgHelper + c *HelperCommon + refHelper *RefsHelper + commitsHelper *CommitsHelper + gpgHelper *GpgHelper + mergeAndRebaseHelper *MergeAndRebaseHelper } func NewWorkingTreeHelper( @@ -25,12 +28,14 @@ func NewWorkingTreeHelper( refHelper *RefsHelper, commitsHelper *CommitsHelper, gpgHelper *GpgHelper, + mergeAndRebaseHelper *MergeAndRebaseHelper, ) *WorkingTreeHelper { return &WorkingTreeHelper{ - c: c, - refHelper: refHelper, - commitsHelper: commitsHelper, - gpgHelper: gpgHelper, + c: c, + refHelper: refHelper, + commitsHelper: commitsHelper, + gpgHelper: gpgHelper, + mergeAndRebaseHelper: mergeAndRebaseHelper, } } @@ -247,3 +252,135 @@ func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefi return self.c.UserConfig().Git.CommitPrefix } + +func (self *WorkingTreeHelper) mergeFile(filepath string, strategy string) (string, error) { + if self.c.Git().Version.IsOlderThan(2, 43, 0) { + return self.mergeFileWithTempFiles(filepath, strategy) + } + + return self.mergeFileWithObjectIDs(filepath, strategy) +} + +func (self *WorkingTreeHelper) mergeFileWithTempFiles(filepath string, strategy string) (string, error) { + showToTempFile := func(stage int, label string) (string, error) { + output, err := self.c.Git().WorkingTree.ShowFileAtStage(filepath, stage) + if err != nil { + return "", err + } + + f, err := os.CreateTemp(self.c.GetConfig().GetTempDir(), "mergefile-"+label+"-*") + if err != nil { + return "", err + } + defer f.Close() + + if _, err := f.Write([]byte(output)); err != nil { + return "", err + } + + return f.Name(), nil + } + + baseFilepath, err := showToTempFile(1, "base") + if err != nil { + return "", err + } + defer os.Remove(baseFilepath) + + oursFilepath, err := showToTempFile(2, "ours") + if err != nil { + return "", err + } + defer os.Remove(oursFilepath) + + theirsFilepath, err := showToTempFile(3, "theirs") + if err != nil { + return "", err + } + defer os.Remove(theirsFilepath) + + return self.c.Git().WorkingTree.MergeFileForFiles(strategy, oursFilepath, baseFilepath, theirsFilepath) +} + +func (self *WorkingTreeHelper) mergeFileWithObjectIDs(filepath, strategy string) (string, error) { + baseID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 1) + if err != nil { + return "", err + } + + oursID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 2) + if err != nil { + return "", err + } + + theirsID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 3) + if err != nil { + return "", err + } + + return self.c.Git().WorkingTree.MergeFileForObjectIDs(strategy, oursID, baseID, theirsID) +} + +func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []string) error { + onMergeStrategySelected := func(strategy string) error { + for _, filepath := range selectedFilepaths { + output, err := self.mergeFile(filepath, strategy) + if err != nil { + return err + } + + if err = os.WriteFile(filepath, []byte(output), 0o644); err != nil { + return err + } + } + + err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + return err + } + + cmdColor := style.FgBlue + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MergeConflictOptionsTitle, + Items: []*types.MenuItem{ + { + LabelColumns: []string{ + self.c.Tr.UseCurrentChanges, + cmdColor.Sprint("git merge-file --ours"), + }, + OnPress: func() error { + return onMergeStrategySelected("--ours") + }, + Key: 'c', + }, + { + LabelColumns: []string{ + self.c.Tr.UseIncomingChanges, + cmdColor.Sprint("git merge-file --theirs"), + }, + OnPress: func() error { + return onMergeStrategySelected("--theirs") + }, + Key: 'i', + }, + { + LabelColumns: []string{ + self.c.Tr.UseBothChanges, + cmdColor.Sprint("git merge-file --union"), + }, + OnPress: func() error { + return onMergeStrategySelected("--union") + }, + Key: 'b', + }, + { + LabelColumns: []string{ + self.c.Tr.OpenMergeTool, + cmdColor.Sprint("git mergetool"), + }, + OnPress: self.OpenMergeTool, + Key: 'm', + }, + }, + }) +} diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 23c53f08857..dc358bb0318 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -112,10 +112,11 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) Tag: "navigation", }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeTool), - Handler: self.c.Helpers().WorkingTree.OpenMergeTool, - Description: self.c.Tr.OpenMergeTool, - Tooltip: self.c.Tr.OpenMergeToolTooltip, + Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Handler: self.openMergeConflictMenu, + Description: self.c.Tr.ViewMergeConflictOptions, + Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, + OpensMenu: true, DisplayOnScreen: true, }, { @@ -320,6 +321,11 @@ func (self *MergeConflictsController) onLastConflictResolved() { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}) } +func (self *MergeConflictsController) openMergeConflictMenu() error { + filepath := self.context().GetState().GetPath() + return self.c.Helpers().WorkingTree.CreateMergeConflictMenu([]string{filepath}) +} + func (self *MergeConflictsController) withRenderAndFocus(f func() error) func() error { return self.withLock(func() error { if err := f(); err != nil { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d162d20dc20..b750d8ff715 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -63,7 +63,6 @@ type TranslationSet struct { ToggleTreeViewTooltip string OpenDiffTool string OpenMergeTool string - OpenMergeToolTooltip string Refresh string RefreshTooltip string Push string @@ -898,6 +897,13 @@ type TranslationSet struct { BreakingChangesTitle string BreakingChangesMessage string BreakingChangesByVersion map[string]string + ViewMergeConflictOptions string + ViewMergeConflictOptionsTooltip string + NoFilesWithMergeConflicts string + MergeConflictOptionsTitle string + UseCurrentChanges string + UseIncomingChanges string + UseBothChanges string } type Bisect struct { @@ -1136,7 +1142,6 @@ func EnglishTranslationSet() *TranslationSet { ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", OpenDiffTool: "Open external diff tool (git difftool)", OpenMergeTool: "Open external merge tool", - OpenMergeToolTooltip: "Run `git mergetool`.", Refresh: "Refresh", RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.", Push: "Push", @@ -1970,6 +1975,13 @@ func EnglishTranslationSet() *TranslationSet { CustomCommands: "Custom commands", NoApplicableCommandsInThisContext: "(No applicable commands in this context)", SelectCommitsOfCurrentBranch: "Select commits of current branch", + ViewMergeConflictOptions: "View merge conflict options", + ViewMergeConflictOptionsTooltip: "View options for resolving merge conflicts.", + NoFilesWithMergeConflicts: "There are no files with merge conflicts.", + MergeConflictOptionsTitle: "Resolve merge conflicts", + UseCurrentChanges: "Use current changes", + UseIncomingChanges: "Use incoming changes", + UseBothChanges: "Use both", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) diff --git a/pkg/integration/tests/conflicts/merge_file_both.go b/pkg/integration/tests/conflicts/merge_file_both.go new file mode 100644 index 00000000000..08357212530 --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_both.go @@ -0,0 +1,77 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataBoth() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1a +2 +3 +4 +5a +6 +` + incoming = ` +1 +2 +3b +4 +5b +6 +` + final = ` +1a +2 +3b +4 +5a +5b +6 +` + return original, current, incoming, final +} + +var MergeFileBoth = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'union' (both changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataBoth() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataBoth() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use both")). // merge-file --union + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/conflicts/merge_file_current.go b/pkg/integration/tests/conflicts/merge_file_current.go new file mode 100644 index 00000000000..b809174465f --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_current.go @@ -0,0 +1,76 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataCurrent() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1 +2 +3 +4 +5a +6 +` + incoming = ` +1b +2 +3 +4 +5b +6 +` + final = ` +1b +2 +3 +4 +5a +6 +` + return original, current, incoming, final +} + +var MergeFileCurrent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'ours' (current changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataCurrent() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataCurrent() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use current changes")). // merge-file --ours + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/conflicts/merge_file_incoming.go b/pkg/integration/tests/conflicts/merge_file_incoming.go new file mode 100644 index 00000000000..8216b4a4d09 --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_incoming.go @@ -0,0 +1,76 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataIncoming() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1a +2 +3 +4 +5a +6 +` + incoming = ` +1 +2 +3 +4 +5b +6 +` + final = ` +1a +2 +3 +4 +5b +6 +` + return original, current, incoming, final +} + +var MergeFileIncoming = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'theirs' (incoming changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataIncoming() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataIncoming() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use incoming changes")). // merge-file --theirs + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/shared/conflicts.go b/pkg/integration/tests/shared/conflicts.go index 4f4a1954f90..b84c8c7add0 100644 --- a/pkg/integration/tests/shared/conflicts.go +++ b/pkg/integration/tests/shared/conflicts.go @@ -157,3 +157,20 @@ var CreateMergeConflictFileMultiple = func(shell *Shell) { shell.RunCommandExpectError([]string{"git", "merge", "--no-edit", "second-change-branch"}) } + +var CreateMergeConflictFileForMergeFileTests = func(shell *Shell, originalFileContent string, currentChangeFileContent string, incomingChangeFileContent string) { + shell. + NewBranch("original-branch"). + EmptyCommit("one"). + CreateFileAndAdd("file", originalFileContent). + Commit("original"). + NewBranch("current-change-branch"). + UpdateFileAndAdd("file", currentChangeFileContent). + Commit("first change"). + Checkout("original-branch"). + NewBranch("incoming-change-branch"). + UpdateFileAndAdd("file", incomingChangeFileContent). + Commit("second change"). + Checkout("current-change-branch"). + RunCommandExpectError([]string{"git", "merge", "--no-edit", "incoming-change-branch"}) +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a292227b388..ed0299b1877 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -150,6 +150,9 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, conflicts.Filter, + conflicts.MergeFileBoth, + conflicts.MergeFileCurrent, + conflicts.MergeFileIncoming, conflicts.ResolveExternally, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, diff --git a/schema/config.json b/schema/config.json index aa4e41012c6..c9cd926827c 100644 --- a/schema/config.json +++ b/schema/config.json @@ -1115,7 +1115,7 @@ "type": "string", "default": "`" }, - "openMergeTool": { + "openMergeOptions": { "type": "string", "default": "M" },