mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-11-25 22:32:13 +02:00
Add merge menu with conflict resolver (#4889)
Implements https://github.com/jesseduffield/lazygit/issues/2026. I also tried to address issues mentioned in the https://github.com/jesseduffield/lazygit/pull/3477 PR. Previously, pressing `M` opened an external merge tool. Now it opens a merge options menu that allows selecting all conflicts in chosen files as **ours** (HEAD), **theirs** (incoming), or **union** (both), while still providing access to the external merge tool. This uses [git-merge-file](https://git-scm.com/docs/git-merge-file) for a 3-way merge with the `--ours`, `--theirs`, and `--union` flags. This approach avoids the issue mentioned in https://github.com/jesseduffield/lazygit/discussions/1608#discussioncomment-13002595, and correctly applies the chosen conflict resolutions while preserving changes from other branches. The command is executed with `--object-id`, which requires object IDs obtained via `rev-parse`, instead of relying on the standard version that works with full saved files.
This commit is contained in:
@@ -619,7 +619,7 @@ keybinding:
|
||||
viewResetOptions: D
|
||||
fetch: f
|
||||
toggleTreeView: '`'
|
||||
openMergeTool: M
|
||||
openMergeOptions: M
|
||||
openStatusFilter: <c-b>
|
||||
copyFileInfoToClipboard: "y"
|
||||
collapseAll: '-'
|
||||
|
||||
@@ -153,7 +153,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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: `<c-b>` means ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | Return to files panel | |
|
||||
|
||||
## Main panel (normal)
|
||||
|
||||
@@ -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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 外部差分ツールを開く(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. |
|
||||
| `` <esc> `` | ファイルパネルに戻る | |
|
||||
|
||||
## メインパネル(通常)
|
||||
|
||||
@@ -152,7 +152,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | 파일 목록으로 돌아가기 | |
|
||||
|
||||
## 메인 패널 (Normal)
|
||||
@@ -396,7 +396,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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 |
|
||||
|
||||
@@ -78,7 +78,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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: `<c-b>` means ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | Ga terug naar het bestanden paneel | |
|
||||
|
||||
## Normaal
|
||||
|
||||
@@ -193,7 +193,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | Wróć do panelu plików | |
|
||||
|
||||
## Panel główny (zatwierdzanie)
|
||||
@@ -252,7 +252,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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 |
|
||||
|
||||
@@ -78,7 +78,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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: `<c-b>` means ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | Retornar ao painel de arquivos | |
|
||||
|
||||
## Painel principal (patch build)
|
||||
|
||||
@@ -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. |
|
||||
| `` <esc> `` | Вернуться к панели файлов | |
|
||||
|
||||
## Главная панель (сборка патчей)
|
||||
@@ -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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 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 |
|
||||
|
||||
@@ -216,7 +216,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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 使用外部差异比较工具(git difftool) | |
|
||||
| `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. |
|
||||
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
|
||||
| `` f `` | 抓取 | 从远程获取变更 |
|
||||
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
|
||||
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
|
||||
@@ -305,7 +305,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-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. |
|
||||
| `` <esc> `` | 返回文件面板 | |
|
||||
|
||||
## 正在暂存
|
||||
|
||||
@@ -97,7 +97,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-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. |
|
||||
| `` <esc> `` | 返回檔案面板 | |
|
||||
|
||||
## 主面板(預存)
|
||||
@@ -347,7 +347,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-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.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
|
||||
| `` <c-t> `` | 開啟外部差異工具 (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 |
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -897,7 +897,7 @@ keybinding:
|
||||
toggleStagedAll: a
|
||||
viewResetOptions: D
|
||||
fetch: f
|
||||
openMergeTool: M
|
||||
openMergeOptions: M
|
||||
openStatusFilter: <c-b>
|
||||
copyFileInfoToClipboard: "y"
|
||||
collapseAll: '-'
|
||||
|
||||
@@ -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: "<c-b>",
|
||||
ConfirmDiscard: "x",
|
||||
CopyFileInfoToClipboard: "y",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
77
pkg/integration/tests/conflicts/merge_file_both.go
Normal file
77
pkg/integration/tests/conflicts/merge_file_both.go
Normal file
@@ -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))
|
||||
},
|
||||
})
|
||||
76
pkg/integration/tests/conflicts/merge_file_current.go
Normal file
76
pkg/integration/tests/conflicts/merge_file_current.go
Normal file
@@ -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))
|
||||
},
|
||||
})
|
||||
76
pkg/integration/tests/conflicts/merge_file_incoming.go
Normal file
76
pkg/integration/tests/conflicts/merge_file_incoming.go
Normal file
@@ -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))
|
||||
},
|
||||
})
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1115,7 +1115,7 @@
|
||||
"type": "string",
|
||||
"default": "`"
|
||||
},
|
||||
"openMergeTool": {
|
||||
"openMergeOptions": {
|
||||
"type": "string",
|
||||
"default": "M"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user