From e9d5155693916417fe9151b8f51b64acffded90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1nos=20Mik=C3=B3?= Date: Wed, 7 Oct 2026 12:10:47 +0200 Subject: [PATCH] Show a repos list when lazygit starts in a directory of repos --- docs-master/Config.md | 7 + docs-master/keybindings/Keybindings_en.md | 9 ++ docs-master/keybindings/Keybindings_ja.md | 9 ++ docs-master/keybindings/Keybindings_ko.md | 9 ++ docs-master/keybindings/Keybindings_nl.md | 9 ++ docs-master/keybindings/Keybindings_pl.md | 9 ++ docs-master/keybindings/Keybindings_pt.md | 9 ++ docs-master/keybindings/Keybindings_ru.md | 9 ++ docs-master/keybindings/Keybindings_zh-CN.md | 9 ++ docs-master/keybindings/Keybindings_zh-TW.md | 9 ++ pkg/app/app.go | 74 +++++++---- pkg/cheatsheet/generate.go | 1 + pkg/commands/git_commands/repo_discovery.go | 41 ++++++ .../git_commands/repo_discovery_test.go | 122 ++++++++++++++++++ pkg/commands/models/repo.go | 16 +++ pkg/config/side_panel.go | 24 ++++ pkg/config/side_panel_test.go | 52 ++++++++ pkg/config/user_config.go | 10 ++ pkg/config/user_config_validation.go | 1 + pkg/gui/context/context.go | 4 + pkg/gui/context/repos_context.go | 59 +++++++++ pkg/gui/context/setup.go | 1 + pkg/gui/controllers.go | 7 + pkg/gui/controllers/global_controller.go | 3 +- pkg/gui/controllers/helpers/refresh_helper.go | 110 ++++++++++++++-- pkg/gui/controllers/helpers/repos_helper.go | 89 +++++++++++-- .../controllers/helpers/repos_helper_test.go | 100 ++++++++++++++ pkg/gui/controllers/repos_controller.go | 110 ++++++++++++++++ pkg/gui/dummies.go | 2 +- pkg/gui/gui.go | 46 ++++++- pkg/gui/presentation/repos.go | 34 +++++ pkg/gui/presentation/repos_test.go | 41 ++++++ pkg/gui/repo_list_seq_test.go | 21 +++ pkg/gui/side_panels.go | 3 + pkg/gui/types/common.go | 5 + pkg/gui/types/refresh.go | 1 + pkg/gui/types/views.go | 1 + pkg/gui/views.go | 2 + pkg/i18n/english.go | 6 + pkg/integration/components/views.go | 4 + .../tests/multi_repo/empty_dir_fallback.go | 26 ++++ .../multi_repo/new_branch_updates_row.go | 30 +++++ pkg/integration/tests/multi_repo/shared.go | 39 ++++++ .../tests/multi_repo/skip_broken_repos.go | 28 ++++ .../tests/multi_repo/start_in_parent.go | 25 ++++ .../multi_repo/start_in_symlinked_parent.go | 27 ++++ .../tests/multi_repo/switch_repo.go | 32 +++++ pkg/integration/tests/test_list.go | 7 + schema-master/config.json | 18 +++ 49 files changed, 1259 insertions(+), 51 deletions(-) create mode 100644 pkg/commands/git_commands/repo_discovery.go create mode 100644 pkg/commands/git_commands/repo_discovery_test.go create mode 100644 pkg/commands/models/repo.go create mode 100644 pkg/config/side_panel_test.go create mode 100644 pkg/gui/context/repos_context.go create mode 100644 pkg/gui/controllers/repos_controller.go create mode 100644 pkg/gui/presentation/repos.go create mode 100644 pkg/gui/presentation/repos_test.go create mode 100644 pkg/gui/repo_list_seq_test.go create mode 100644 pkg/integration/tests/multi_repo/empty_dir_fallback.go create mode 100644 pkg/integration/tests/multi_repo/new_branch_updates_row.go create mode 100644 pkg/integration/tests/multi_repo/shared.go create mode 100644 pkg/integration/tests/multi_repo/skip_broken_repos.go create mode 100644 pkg/integration/tests/multi_repo/start_in_parent.go create mode 100644 pkg/integration/tests/multi_repo/start_in_symlinked_parent.go create mode 100644 pkg/integration/tests/multi_repo/switch_repo.go diff --git a/docs-master/Config.md b/docs-master/Config.md index d35455c845f..ead4bfd8938 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -581,6 +581,13 @@ worktree: # A leading "~" is expanded to your home directory, so "~/worktrees" works. defaultPath: "" +# Config relating to multi-repo mode, which lazygit uses when you start it in a +# directory that is not a git repo but contains git repos +multiRepo: + # How many directory levels below the start directory lazygit searches for git + # repos. A value of 1 searches only the direct subdirectories. + maxDepth: 2 + # Periodic update checks update: # One of: 'prompt' (default) | 'background' | 'never' diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 992f34dc90b..d917bb6531b 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -303,6 +303,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | | `` / `` | Filter the current view by text | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Switch | Switch to the selected repository. | +| `` e `` | Edit config file | Open file in external editor. | +| `` u `` | Check for update | | +| `` / `` | Filter the current view by text | | + ## Secondary | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 2b1e534e4a6..f8f78d993a3 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -60,6 +60,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 確認 | | | `` `` | 閉じる/キャンセル | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | チェックアウト(切り替え) | Switch to the selected repository. | +| `` e `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 | +| `` u `` | 更新を確認 | | +| `` / `` | 現在のビューをテキストでフィルタリング | | + ## コミット | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 34a91e29ace..d7f0b10bf12 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -80,6 +80,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 커밋 보기 | | | `` / `` | Filter the current view by text | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Switch | Switch to the selected repository. | +| `` e `` | 설정 파일 수정 | Open file in external editor. | +| `` u `` | 업데이트 확인 | | +| `` / `` | Filter the current view by text | | + ## Secondary | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 136cb770c0e..80c055d14f2 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -303,6 +303,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | | `` / `` | Filter the current view by text | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Switch | Switch to the selected repository. | +| `` e `` | Verander config bestand | Open bestand in externe editor. | +| `` u `` | Check voor updates | | +| `` / `` | Filter the current view by text | | + ## Secondary | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 02d568b1d8e..8fbe18c42d3 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -307,6 +307,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Potwierdź | | | `` `` | Zamknij | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Przełącz | Switch to the selected repository. | +| `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | +| `` u `` | Sprawdź aktualizacje | | +| `` / `` | Filtruj bieżący widok po tekście | | + ## Schowek | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 001fd7cb84b..26fb961f17e 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -312,6 +312,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | | `` / `` | Filtrar a visualização atual por texto | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Switch | Switch to the selected repository. | +| `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | +| `` u `` | Verificar atualização | | +| `` / `` | Filtrar a visualização atual por texto | | + ## Secundário | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3f25e0062d4..1bf8e025b87 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -60,6 +60,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Подтвердить | | | `` `` | Закрыть/отменить | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Switch | Switch to the selected repository. | +| `` e `` | Редактировать файл конфигурации | Open file in external editor. | +| `` u `` | Проверить обновления | | +| `` / `` | Filter the current view by text | | + ## Worktrees | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index befda4cc2e5..dafeb17304c 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -53,6 +53,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` ] `` | 下一个标签 | | | `` [ `` | 上一个标签 | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | 切换 | Switch to the selected repository. | +| `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 | +| `` u `` | 检查更新 | | +| `` / `` | 通过文本过滤当前视图 | | + ## 子提交 | Key | Action | Info | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index c44b154bc95..839b11a605c 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -53,6 +53,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` ] `` | 下一個索引標籤 | | | `` [ `` | 上一個索引標籤 | | +## Repositories + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | 切換 | Switch to the selected repository. | +| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | +| `` u `` | 檢查更新 | | +| `` / `` | 搜尋 | | + ## 主面板(一般) | Key | Action | Info | diff --git a/pkg/app/app.go b/pkg/app/app.go index 11f50c8b5f8..2ae2eff1bc4 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -123,7 +123,7 @@ func NewApp(config config.AppConfigurer, test integrationTypes.IntegrationTest, common.Log.Infof("Error getting repo paths: %v", err) } - showRecentRepos, err := app.setupRepo(repoPaths) + showRecentRepos, multiRepoRoot, err := app.setupRepo(repoPaths) if err != nil { return app, err } @@ -133,7 +133,7 @@ func NewApp(config config.AppConfigurer, test integrationTypes.IntegrationTest, showRecentRepos = true } - app.Gui, err = gui.NewGui(common, config, gitVersion, updater, showRecentRepos, dirName, test) + app.Gui, err = gui.NewGui(common, config, gitVersion, updater, showRecentRepos, dirName, multiRepoRoot, test) if err != nil { return app, err } @@ -170,18 +170,7 @@ func isDirectoryAGitRepository(dir string) (bool, error) { func openRecentRepo(app *App) bool { for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { - if err := os.Chdir(repoDir); err == nil { - // We're still in setup, before the gui exists, so we can't show the approval popup - // that DispatchSwitchTo offers for blocked .envrc files; just log and move on. - // Also, the logs only go to the debug log, not the Command Log, because that's not - // available yet, either. - result := direnv.Load(app.OSCommand.Cmd) - if result.Message != "" { - app.Log.WithField("message", result.Message).Info("direnv") - } - if result.Err != nil { - app.Log.WithError(result.Err).Warn("direnv load failed") - } + if enterRepo(app, repoDir) { return true } } @@ -190,23 +179,62 @@ func openRecentRepo(app *App) bool { return false } +// isUsableRepo runs the repo check of NewGitCommand. Discovery only sees that a +// .git exists, and a stale worktree link or an empty .git dir would make +// NewGitCommand fail and abort startup. +func isUsableRepo(app *App, dir string) bool { + repoPaths, err := git_commands.GetRepoPathsForDir(dir, app.OSCommand.Cmd) + return err == nil && !repoPaths.IsBareRepo() +} + +func enterRepo(app *App, repoDir string) bool { + if err := os.Chdir(repoDir); err != nil { + return false + } + + // We're still in setup, before the gui exists, so we can't show the approval popup + // that DispatchSwitchTo offers for blocked .envrc files; just log and move on. + // Also, the logs only go to the debug log, not the Command Log, because that's not + // available yet, either. + result := direnv.Load(app.OSCommand.Cmd) + if result.Message != "" { + app.Log.WithField("message", result.Message).Info("direnv") + } + if result.Err != nil { + app.Log.WithError(result.Err).Warn("direnv load failed") + } + return true +} + func (app *App) setupRepo( repoPaths *git_commands.RepoPaths, -) (bool, error) { +) (bool, string, error) { if env.GetGitDirEnv() != "" { // we've been given the git dir directly. Skip setup - return false, nil + return false, "", nil } // if we are not in a git repo, we ask if we want to `git init` if repoPaths == nil { cwd, err := os.Getwd() if err != nil { - return false, err + return false, "", err } if isRepo, err := isDirectoryAGitRepository(cwd); isRepo { - return false, err + return false, "", err + } + + // git reports the current repo's path with symlinks resolved, and the + // repos list compares against it. + root := cwd + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { + root = resolved + } + for _, repo := range git_commands.DiscoverRepos(root, app.UserConfig().MultiRepo.MaxDepth) { + if isUsableRepo(app, repo) && enterRepo(app, repo) { + return false, root, nil + } } var shouldInitRepo bool @@ -243,15 +271,15 @@ func (app *App) setupRepo( args = append(args, initialBranchArg) } if err := app.OSCommand.Cmd.New(args).Run(); err != nil { - return false, err + return false, "", err } - return false, nil + return false, "", nil } // check if we have a recent repo we can open if openRecentRepo(app) { - return true, nil + return true, "", nil } fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories) @@ -270,14 +298,14 @@ func (app *App) setupRepo( } if openRecentRepo(app) { - return true, nil + return true, "", nil } fmt.Println(app.Tr.NoRecentRepositories) os.Exit(1) } - return false, nil + return false, "", nil } func (app *App) Run(startArgs appTypes.StartArgs) error { diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index f662eb25d85..3c2e7f8a2f5 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -106,6 +106,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string { "localBranches": tr.LocalBranchesTitle, "files": tr.FilesTitle, "status": tr.StatusTitle, + "repos": tr.ReposTitle, "submodules": tr.SubmodulesTitle, "subCommits": tr.SubCommitsTitle, "remoteBranches": tr.RemoteBranchesTitle, diff --git a/pkg/commands/git_commands/repo_discovery.go b/pkg/commands/git_commands/repo_discovery.go new file mode 100644 index 00000000000..9aaf19574f9 --- /dev/null +++ b/pkg/commands/git_commands/repo_discovery.go @@ -0,0 +1,41 @@ +package git_commands + +import ( + "os" + "path/filepath" + "slices" + "strings" +) + +// DiscoverRepos returns the absolute paths of the git repos below root, down to +// maxDepth directory levels, sorted by path. It does not look inside a repo it +// found, it skips hidden directories, and it does not follow symlinks, so a +// symlink loop can't trap it. It skips directories that it can't read. +func DiscoverRepos(root string, maxDepth int) []string { + var repos []string + + var walk func(dir string, depth int) + walk = func(dir string, depth int) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + path := filepath.Join(dir, entry.Name()) + if _, err := os.Lstat(filepath.Join(path, ".git")); err == nil { + repos = append(repos, path) + continue + } + if depth < maxDepth { + walk(path, depth+1) + } + } + } + walk(root, 1) + + slices.Sort(repos) + return repos +} diff --git a/pkg/commands/git_commands/repo_discovery_test.go b/pkg/commands/git_commands/repo_discovery_test.go new file mode 100644 index 00000000000..c8b3cc1e7a7 --- /dev/null +++ b/pkg/commands/git_commands/repo_discovery_test.go @@ -0,0 +1,122 @@ +package git_commands + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDiscoverRepos(t *testing.T) { + makeRepo := func(t *testing.T, root string, rel string) { + t.Helper() + assert.NoError(t, os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755)) + } + makeDir := func(t *testing.T, root string, rel string) { + t.Helper() + assert.NoError(t, os.MkdirAll(filepath.Join(root, rel), 0o755)) + } + + scenarios := []struct { + name string + setup func(t *testing.T, root string) + maxDepth int + expected []string + }{ + { + name: "empty directory", + setup: func(t *testing.T, root string) { + t.Helper() + }, + maxDepth: 2, + expected: nil, + }, + { + name: "direct children only at depth 1", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, "b") + makeRepo(t, root, "a") + makeRepo(t, root, "org/c") + }, + maxDepth: 1, + expected: []string{"a", "b"}, + }, + { + name: "nested repos within depth", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, "a") + makeRepo(t, root, "org/c") + makeRepo(t, root, "org/deep/d") + }, + maxDepth: 2, + expected: []string{"a", "org/c"}, + }, + { + name: "stops at a repo", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, "a") + makeRepo(t, root, "a/sub") + }, + maxDepth: 3, + expected: []string{"a"}, + }, + { + name: "a .git file counts as a repo", + setup: func(t *testing.T, root string) { + t.Helper() + makeDir(t, root, "wt") + assert.NoError(t, os.WriteFile(filepath.Join(root, "wt", ".git"), []byte("gitdir: /x\n"), 0o644)) + }, + maxDepth: 1, + expected: []string{"wt"}, + }, + { + name: "skips hidden directories", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, ".hidden/a") + makeRepo(t, root, ".b") + }, + maxDepth: 2, + expected: nil, + }, + { + name: "does not follow symlinks", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, "a") + assert.NoError(t, os.Symlink(filepath.Join(root, "a"), filepath.Join(root, "link"))) + assert.NoError(t, os.Symlink(root, filepath.Join(root, "loop"))) + }, + maxDepth: 5, + expected: []string{"a"}, + }, + { + name: "sorted by path", + setup: func(t *testing.T, root string) { + t.Helper() + makeRepo(t, root, "a-c") + makeRepo(t, root, "a/b") + }, + maxDepth: 2, + expected: []string{"a-c", "a/b"}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + root := t.TempDir() + s.setup(t, root) + + var expected []string + for _, rel := range s.expected { + expected = append(expected, filepath.Join(root, rel)) + } + assert.Equal(t, expected, DiscoverRepos(root, s.maxDepth)) + }) + } +} diff --git a/pkg/commands/models/repo.go b/pkg/commands/models/repo.go new file mode 100644 index 00000000000..40adff1a402 --- /dev/null +++ b/pkg/commands/models/repo.go @@ -0,0 +1,16 @@ +package models + +// Repo is one git repo in the multi-repo list. +type Repo struct { + // Absolute path of the repo's working tree + Path string + // Path relative to the directory that lazygit was started in + Name string + Branch string + // True if the working tree has changes + Dirty bool +} + +func (r *Repo) ID() string { + return r.Path +} diff --git a/pkg/config/side_panel.go b/pkg/config/side_panel.go index 307ed0c1d86..5130a49dc18 100644 --- a/pkg/config/side_panel.go +++ b/pkg/config/side_panel.go @@ -16,6 +16,7 @@ type SidePanel []string // here; a test enforces that the two stay in sync. var ValidSidePanelTabs = []string{ "status", + "repos", "files", "worktrees", "submodules", @@ -52,3 +53,26 @@ func (SidePanel) JSONSchema() *jsonschema.Schema { Items: &jsonschema.Schema{Type: "string", Enum: names}, } } + +// SidePanelsForMultiRepo puts repos in the place of status, or first if there +// is no status, because multi-repo mode focuses repos at startup. +func SidePanelsForMultiRepo(panels []SidePanel) []SidePanel { + hasTab := func(name string) bool { + return lo.SomeBy(panels, func(panel SidePanel) bool { return lo.Contains(panel, name) }) + } + if hasTab("repos") { + return panels + } + if !hasTab("status") { + return append([]SidePanel{{"repos"}}, panels...) + } + + return lo.Map(panels, func(panel SidePanel, _ int) SidePanel { + return lo.Map(panel, func(name string, _ int) string { + if name == "status" { + return "repos" + } + return name + }) + }) +} diff --git a/pkg/config/side_panel_test.go b/pkg/config/side_panel_test.go new file mode 100644 index 00000000000..b280f25fdd2 --- /dev/null +++ b/pkg/config/side_panel_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSidePanelsForMultiRepo(t *testing.T) { + scenarios := []struct { + name string + panels []SidePanel + expected []SidePanel + }{ + { + name: "replaces status", + panels: []SidePanel{{"status"}, {"files", "worktrees"}}, + expected: []SidePanel{{"repos"}, {"files", "worktrees"}}, + }, + { + name: "replaces status inside a group", + panels: []SidePanel{{"files", "status"}}, + expected: []SidePanel{{"files", "repos"}}, + }, + { + name: "keeps the config if it lists repos already", + panels: []SidePanel{{"status"}, {"repos", "files"}}, + expected: []SidePanel{{"status"}, {"repos", "files"}}, + }, + { + name: "adds repos as the first panel if the config has no status", + panels: []SidePanel{{"files"}}, + expected: []SidePanel{{"repos"}, {"files"}}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + original := cloneSidePanels(s.panels) + assert.Equal(t, s.expected, SidePanelsForMultiRepo(s.panels)) + assert.Equal(t, original, s.panels, "input must not change") + }) + } +} + +func cloneSidePanels(panels []SidePanel) []SidePanel { + result := make([]SidePanel, len(panels)) + for i, panel := range panels { + result[i] = append(SidePanel{}, panel...) + } + return result +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index eedde41e09a..9f0232401f5 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -13,6 +13,8 @@ type UserConfig struct { Git GitConfig `yaml:"git"` // Config relating to git worktrees Worktree WorktreeConfig `yaml:"worktree"` + // Config relating to multi-repo mode, which lazygit uses when you start it in a directory that is not a git repo but contains git repos + MultiRepo MultiRepoConfig `yaml:"multiRepo"` // Periodic update checks Update UpdateConfig `yaml:"update"` // Background refreshes @@ -455,6 +457,11 @@ type WorktreeConfig struct { DefaultPath string `yaml:"defaultPath"` } +type MultiRepoConfig struct { + // How many directory levels below the start directory lazygit searches for git repos. A value of 1 searches only the direct subdirectories. + MaxDepth int `yaml:"maxDepth" jsonschema:"minimum=1"` +} + type UpdateConfig struct { // One of: 'prompt' (default) | 'background' | 'never' Method string `yaml:"method" jsonschema:"enum=prompt,enum=background,enum=never"` @@ -996,6 +1003,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { Worktree: WorktreeConfig{ DefaultPath: "", }, + MultiRepo: MultiRepoConfig{ + MaxDepth: 2, + }, Refresher: RefresherConfig{ RefreshInterval: 10, FetchInterval: 60, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index ef6f6c382f3..f0bc7f5e7c6 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -219,6 +219,7 @@ func validateCustomCommandKey(key Keybinding) error { var ValidCustomCommandContexts = []string{ "global", "status", + "repos", "files", "localBranches", "remotes", diff --git a/pkg/gui/context/context.go b/pkg/gui/context/context.go index 280f456427d..2f977fed46f 100644 --- a/pkg/gui/context/context.go +++ b/pkg/gui/context/context.go @@ -10,6 +10,7 @@ const ( GLOBAL_CONTEXT_KEY types.ContextKey = "global" STATUS_CONTEXT_KEY types.ContextKey = "status" + REPOS_CONTEXT_KEY types.ContextKey = "repos" SNAKE_CONTEXT_KEY types.ContextKey = "snake" FILES_CONTEXT_KEY types.ContextKey = "files" LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" @@ -49,6 +50,7 @@ const ( var AllContextKeys = []types.ContextKey{ GLOBAL_CONTEXT_KEY, STATUS_CONTEXT_KEY, + REPOS_CONTEXT_KEY, FILES_CONTEXT_KEY, LOCAL_BRANCHES_CONTEXT_KEY, REMOTES_CONTEXT_KEY, @@ -77,6 +79,7 @@ var AllContextKeys = []types.ContextKey{ type ContextTree struct { Global types.Context Status types.Context + Repos *ReposContext Snake types.Context Files *WorkingTreeContext Menu *MenuContext @@ -117,6 +120,7 @@ func (self *ContextTree) Flatten() []types.Context { return []types.Context{ self.Global, self.Status, + self.Repos, self.Snake, self.Submodules, self.Worktrees, diff --git a/pkg/gui/context/repos_context.go b/pkg/gui/context/repos_context.go new file mode 100644 index 00000000000..917c736b3b4 --- /dev/null +++ b/pkg/gui/context/repos_context.go @@ -0,0 +1,59 @@ +package context + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type ReposContext struct { + *FilteredListViewModel[*models.Repo] + *ListContextTrait +} + +var _ types.IListContext = (*ReposContext)(nil) + +func NewReposContext(c *ContextCommon) *ReposContext { + viewModel := NewFilteredListViewModel( + func() []*models.Repo { return c.State().GetRepoList() }, + func(repo *models.Repo) []string { + return []string{repo.Name, repo.Branch} + }, + ) + + getDisplayStrings := func(_ int, _ int) [][]string { + return presentation.GetRepoListDisplayStrings( + viewModel.GetFilteredList(), + c.Git().RepoPaths.WorktreePath(), + ) + } + + return &ReposContext{ + FilteredListViewModel: viewModel, + ListContextTrait: &ListContextTrait{ + Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ + View: c.Views().Repos, + WindowName: "repos", + Key: REPOS_CONTEXT_KEY, + Kind: types.SIDE_CONTEXT, + Focusable: true, + })), + ListRenderer: ListRenderer{ + list: viewModel, + getDisplayStrings: getDisplayStrings, + }, + c: c, + }, + } +} + +// SelectRepo moves the selection to the repo with the given path, if the +// filtered list shows it. +func (self *ReposContext) SelectRepo(path string) { + for i, repo := range self.GetFilteredList() { + if repo.Path == path { + self.SetSelection(i) + return + } + } +} diff --git a/pkg/gui/context/setup.go b/pkg/gui/context/setup.go index 04d035388ee..9f6e1262408 100644 --- a/pkg/gui/context/setup.go +++ b/pkg/gui/context/setup.go @@ -25,6 +25,7 @@ func NewContextTree(c *ContextCommon) *ContextTree { Focusable: true, }), ), + Repos: NewReposContext(c), Files: NewWorkingTreeContext(c), Submodules: NewSubmodulesContext(c), Menu: NewMenuContext(c), diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 67ff1c810da..aaeca7bcfb7 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -63,6 +63,7 @@ func (gui *Gui) resetHelpersAndControllers() { mergeConflictsHelper, worktreeHelper, searchHelper, + reposHelper, ) diffLineHelper := helpers.NewDiffLineHelper(helperCommon) diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper) @@ -169,6 +170,7 @@ func (gui *Gui) resetHelpersAndControllers() { func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches }, ) worktreesController := controllers.NewWorktreesController(common) + reposController := controllers.NewReposController(common) undoController := controllers.NewUndoController(common) globalController := controllers.NewGlobalController(common) contextLinesController := controllers.NewContextLinesController(common) @@ -206,6 +208,7 @@ func (gui *Gui) resetHelpersAndControllers() { // allow for navigating between side window contexts for _, context := range []types.Context{ gui.State.Contexts.Status, + gui.State.Contexts.Repos, gui.State.Contexts.Remotes, gui.State.Contexts.Worktrees, gui.State.Contexts.Tags, @@ -325,6 +328,10 @@ func (gui *Gui) resetHelpersAndControllers() { worktreesController, ) + controllers.AttachControllers(gui.State.Contexts.Repos, + reposController, + ) + controllers.AttachControllers(gui.State.Contexts.Stash, stashController, ) diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index a51b70fc5e8..0cddcc2a9ba 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -166,7 +167,7 @@ func (self *GlobalController) createCustomPatchOptionsMenu() error { } func (self *GlobalController) refresh() error { - self.c.Refresh(types.RefreshOptions{}) + self.c.Refresh(types.RefreshOptions{Scope: helpers.DefaultScopesWithRepos()}) return nil } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 605319a8e2a..fa98b3c6799 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -2,6 +2,7 @@ package helpers import ( "path/filepath" + "slices" "strings" "sync" "sync/atomic" @@ -33,6 +34,7 @@ type RefreshHelper struct { mergeConflictsHelper *MergeConflictsHelper worktreeHelper *WorktreeHelper searchHelper *SearchHelper + reposHelper *ReposHelper // Tracks repos for which the user has dismissed the "select base GitHub remote" // prompt, to avoid re-prompting on every subsequent refresh within the same session. @@ -63,6 +65,7 @@ func NewRefreshHelper( mergeConflictsHelper *MergeConflictsHelper, worktreeHelper *WorktreeHelper, searchHelper *SearchHelper, + reposHelper *ReposHelper, ) *RefreshHelper { return &RefreshHelper{ c: c, @@ -71,9 +74,32 @@ func NewRefreshHelper( mergeConflictsHelper: mergeConflictsHelper, worktreeHelper: worktreeHelper, searchHelper: searchHelper, + reposHelper: reposHelper, } } +// No REPOS: it runs git status in every listed repo, too slow for routine +// refreshes such as the one on every terminal focus event. +var defaultRefreshScopes = []types.RefreshableView{ + types.COMMITS, + types.BRANCHES, + types.FILES, + types.STASH, + types.REFLOG, + types.TAGS, + types.REMOTES, + types.WORKTREES, + types.STATUS, + types.BISECT_INFO, + types.PULL_REQUESTS, +} + +// DefaultScopesWithRepos returns the default scopes plus REPOS, for the +// refreshes that reload the whole multi-repo list. +func DefaultScopesWithRepos() []types.RefreshableView { + return append(slices.Clone(defaultRefreshScopes), types.REPOS) +} + func (self *RefreshHelper) Refresh(options types.RefreshOptions) { self.performRefresh(options, false, false) } @@ -242,19 +268,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { - scopeSet = set.NewFromSlice([]types.RefreshableView{ - types.COMMITS, - types.BRANCHES, - types.FILES, - types.STASH, - types.REFLOG, - types.TAGS, - types.REMOTES, - types.WORKTREES, - types.STATUS, - types.BISECT_INFO, - types.PULL_REQUESTS, - }) + scopeSet = set.NewFromSlice(defaultRefreshScopes) } else { scopeSet = set.NewFromSlice(options.Scope) } @@ -330,6 +344,12 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } + if scopeSet.Includes(types.REPOS) { + refresh("repos", func() { self.refreshRepos() }) + } else if len(options.Scope) == 0 { + refresh("current repo row", func() { self.refreshCurrentRepoRow(env) }) + } + branchesAndRemotesWg := sync.WaitGroup{} // The pull-request fetch (below) needs the just-loaded branches and // remotes. Their model writes are bounced onto the UI thread, so the @@ -624,6 +644,69 @@ func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types. self.SetRefsSnapshot(snapshot) } +// refreshRepos loads the multi-repo list. The branches load inline, so the +// panel fills fast. The dirty checks run as a background task, because a refresh +// stays busy until all its scopes finish, and many slow git status calls must +// not block repo switches. The writes skip onUIThreadUnlessRepoChanged: the list +// belongs to the Gui, so it has to survive a repo switch. +func (self *RefreshHelper) refreshRepos() { + root := self.c.State().GetMultiRepoRoot() + if root == "" { + return + } + + seq := self.c.State().NextRepoListLoadSeq() + repos := self.reposHelper.LoadRepoBranches(root) + + self.c.OnUIThread(func() error { + if len(self.c.State().GetRepoList()) == 0 { + self.setRepoList(repos, seq) + } + return nil + }) + + self.c.OnWorkerBackground(func(gocui.Task) error { + stale := func() bool { return !self.c.State().IsLatestRepoListLoad(seq) } + withDirty := self.reposHelper.WithDirtyState(repos, stale) + if stale() { + return nil + } + self.c.OnUIThreadBackground(func() error { + self.setRepoList(withDirty, seq) + return nil + }) + return nil + }) +} + +func (self *RefreshHelper) setRepoList(repos []*models.Repo, seq int64) { + if !self.c.State().IsLatestRepoListLoad(seq) { + return + } + self.applyRepoList(repos) +} + +// refreshCurrentRepoRow patches the list that is current on the UI thread, so +// it can't bring back an older list. A repo switch drops the patch, but reloads +// the whole list. +func (self *RefreshHelper) refreshCurrentRepoRow(env refreshEnv) { + if self.c.State().GetMultiRepoRoot() == "" { + return + } + + current := self.reposHelper.LoadRepo(env.git.RepoPaths.WorktreePath()) + self.onUIThreadUnlessRepoChanged(env, func() { + self.applyRepoList(withRepoRowUpdated(self.c.State().GetRepoList(), current)) + }) +} + +func (self *RefreshHelper) applyRepoList(repos []*models.Repo) { + self.c.State().SetRepoList(repos) + ctx := self.c.Contexts().Repos + self.searchHelper.ReApplyFilter(ctx) + self.c.PostRefreshUpdate(ctx) +} + func getScopeNames(scopes []types.RefreshableView) []string { scopeNameMap := map[types.RefreshableView]string{ types.COMMITS: "commits", @@ -631,6 +714,7 @@ func getScopeNames(scopes []types.RefreshableView) []string { types.BRANCHES: "branches", types.FILES: "files", types.SUBMODULES: "submodules", + types.REPOS: "repos", types.SUB_COMMITS: "subCommits", types.STASH: "stash", types.REFLOG: "reflog", diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 53018a2e1fe..766914fd9a4 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -14,11 +14,13 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" + "golang.org/x/sync/errgroup" ) type onNewRepoFn func(startArgs appTypes.StartArgs, contextKey types.ContextKey) error @@ -161,6 +163,70 @@ func (self *ReposHelper) askGit(repoPath string, subcommand string, args ...stri return output, output != "" } +// LoadRepoBranches finds the repos below root and reads their branches. +func (self *ReposHelper) LoadRepoBranches(root string) []*models.Repo { + paths := git_commands.DiscoverRepos(root, self.c.UserConfig().MultiRepo.MaxDepth) + return lo.Map(paths, func(path string, _ int) *models.Repo { + name, err := filepath.Rel(root, path) + if err != nil { + name = path + } + return &models.Repo{Path: path, Name: name, Branch: self.getCurrentBranch(path)} + }) +} + +// WithDirtyState returns copies of repos with Dirty set. It leaves repos as is. +// Once stale returns true, the remaining repos are copied without a check. +func (self *ReposHelper) WithDirtyState(repos []*models.Repo, stale func() bool) []*models.Repo { + result := make([]*models.Repo, len(repos)) + var group errgroup.Group + group.SetLimit(8) + for i, repo := range repos { + group.Go(func() error { + updated := *repo + if !stale() { + updated.Dirty = self.isDirty(repo.Path) + } + result[i] = &updated + return nil + }) + } + _ = group.Wait() + return result +} + +// LoadRepo reads the branch and the dirty state of the repo at path. +func (self *ReposHelper) LoadRepo(path string) *models.Repo { + repo := &models.Repo{Path: path, Branch: self.getCurrentBranch(path)} + return self.WithDirtyState([]*models.Repo{repo}, func() bool { return false })[0] +} + +// withRepoRowUpdated returns a copy of repos in which the row of the repo at +// loaded.Path has the branch and dirty state of loaded. +func withRepoRowUpdated(repos []*models.Repo, loaded *models.Repo) []*models.Repo { + return lo.Map(repos, func(repo *models.Repo, _ int) *models.Repo { + if !presentation.IsCurrentRepo(repo, loaded.Path) { + return repo + } + updated := *repo + updated.Branch = loaded.Branch + updated.Dirty = loaded.Dirty + return &updated + }) +} + +// isDirty sets GIT_OPTIONAL_LOCKS=0 because the OS command builder does not, +// and status would otherwise take index.lock in a repo the user may be using. +func (self *ReposHelper) isDirty(repoPath string) bool { + cmdObj := self.c.OS().Cmd.New(git_commands.NewGitCmd("status"). + Dir(repoPath). + Arg("--porcelain", "--untracked-files=normal"). + ToArgv()).DontLog(). + AddEnvVars(git_commands.OptionalLocksEnvVar + "=0") + stdout, _, err := git_commands.ForOtherRepo(cmdObj).RunWithOutputs() + return err == nil && strings.TrimSpace(stdout) != "" +} + func (self *ReposHelper) getCurrentBranch(path string) string { head, ok := readHeadInfo(path) if !ok { @@ -296,16 +362,7 @@ func (self *ReposHelper) recentRepoMenuItem(entry recentRepoEntry, widths recent FilterColumns: []string{entry.nameColumn, entry.branchName, entry.path}, Tooltip: strings.Join(tooltipLines, "\n"), OnPress: func() error { - // Check before clearing the stack, so a refused switch doesn't - // forget the submodule breadcrumb (which would leave escape - // unable to return to the parent repo). - if self.switchRefusedBecauseBusy() { - return nil - } - // if we were in a submodule, we want to forget about that stack of repos - // so that hitting escape in the new repo does nothing - self.c.State().GetRepoPathStack().Clear() - return self.switchTo(entry.path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) + return self.SwitchToTopLevelRepo(entry.path, context.NO_CONTEXT) }, } } @@ -369,6 +426,18 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey return self.switchTo(path, errMsg, contextKey) } +// SwitchToTopLevelRepo switches to the repo at path and forgets the submodule +// stack, so that escape in the new repo does nothing. It checks for an +// in-flight operation before it clears the stack, so that a refused switch +// keeps the submodule breadcrumb. +func (self *ReposHelper) SwitchToTopLevelRepo(path string, contextKey types.ContextKey) error { + if self.switchRefusedBecauseBusy() { + return nil + } + self.c.State().GetRepoPathStack().Clear() + return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey) +} + // switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch // must be refused because a foreground git operation is in flight. Switching // reassigns gui.git and the process cwd, so switching mid-operation would run diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go index 5e6b2e8c78e..48c5a43ee78 100644 --- a/pkg/gui/controllers/helpers/repos_helper_test.go +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -2,10 +2,15 @@ package helpers import ( "os" + "os/exec" "path/filepath" "strings" "testing" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/stretchr/testify/assert" ) @@ -106,3 +111,98 @@ func TestReadHeadInfo(t *testing.T) { }) } } + +type osGuiCommon struct { + types.IGuiCommon + os *oscommands.OSCommand +} + +func (self osGuiCommon) OS() *oscommands.OSCommand { return self.os } + +func TestWithDirtyState(t *testing.T) { + root := t.TempDir() + run := func(dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + assert.NoError(t, err, string(out)) + } + clean := filepath.Join(root, "clean") + dirty := filepath.Join(root, "dirty") + for _, dir := range []string{clean, dirty} { + assert.NoError(t, os.Mkdir(dir, 0o755)) + run(dir, "init", "-q") + } + assert.NoError(t, os.WriteFile(filepath.Join(dirty, "new.txt"), []byte("x"), 0o644)) + + input := []*models.Repo{{Path: clean, Name: "clean"}, {Path: dirty, Name: "dirty"}} + + result := newDirtyTestHelper().WithDirtyState(input, func() bool { return false }) + + assert.Len(t, result, 2) + assert.False(t, result[0].Dirty) + assert.True(t, result[1].Dirty) + assert.Equal(t, "dirty", result[1].Name) + assert.False(t, input[1].Dirty) + assert.NotSame(t, input[1], result[1]) +} + +func newDirtyTestHelper() *ReposHelper { + return &ReposHelper{c: &HelperCommon{ + Common: common.NewDummyCommon(), + IGuiCommon: osGuiCommon{os: oscommands.NewDummyOSCommand()}, + }} +} + +func TestWithDirtyStateNotARepo(t *testing.T) { + dir := t.TempDir() + result := newDirtyTestHelper().WithDirtyState( + []*models.Repo{{Path: dir, Name: "plain"}}, func() bool { return false }) + assert.Len(t, result, 1) + assert.False(t, result[0].Dirty) +} + +func TestWithDirtyStateStaleSkipsChecks(t *testing.T) { + root := t.TempDir() + cmd := exec.Command("git", "init", "-q") + cmd.Dir = root + assert.NoError(t, cmd.Run()) + assert.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("x"), 0o644)) + + result := newDirtyTestHelper().WithDirtyState( + []*models.Repo{{Path: root}}, func() bool { return true }) + assert.False(t, result[0].Dirty) +} + +func TestLoadRepo(t *testing.T) { + root := t.TempDir() + cmd := exec.Command("git", "init", "-q", "-b", "topic") + cmd.Dir = root + assert.NoError(t, cmd.Run()) + assert.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("x"), 0o644)) + + repo := newDirtyTestHelper().LoadRepo(root) + + assert.Equal(t, &models.Repo{Path: root, Branch: "topic", Dirty: true}, repo) +} + +func TestWithRepoRowUpdated(t *testing.T) { + alpha := &models.Repo{Path: filepath.FromSlash("/w/alpha"), Name: "alpha", Branch: "main"} + beta := &models.Repo{Path: filepath.FromSlash("/w/beta"), Name: "beta", Branch: "main"} + repos := []*models.Repo{alpha, beta} + + t.Run("updates the row at the path", func(t *testing.T) { + result := withRepoRowUpdated(repos, &models.Repo{Path: "/w/beta/", Branch: "topic", Dirty: true}) + + assert.Same(t, alpha, result[0]) + assert.Equal(t, &models.Repo{Path: beta.Path, Name: "beta", Branch: "topic", Dirty: true}, result[1]) + assert.Equal(t, "main", beta.Branch, "input must not change") + assert.False(t, beta.Dirty, "input must not change") + }) + + t.Run("keeps the rows if none is at the path", func(t *testing.T) { + result := withRepoRowUpdated(repos, &models.Repo{Path: "/w/gamma", Branch: "topic"}) + + assert.Equal(t, repos, result) + }) +} diff --git a/pkg/gui/controllers/repos_controller.go b/pkg/gui/controllers/repos_controller.go new file mode 100644 index 00000000000..8ac8a273c50 --- /dev/null +++ b/pkg/gui/controllers/repos_controller.go @@ -0,0 +1,110 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type ReposController struct { + baseController + *ListControllerTrait[*models.Repo] + c *ControllerCommon +} + +var _ types.IController = &ReposController{} + +func NewReposController(c *ControllerCommon) *ReposController { + return &ReposController{ + baseController: baseController{}, + ListControllerTrait: NewListControllerTrait( + c, + c.Contexts().Repos, + c.Contexts().Repos.GetSelected, + c.Contexts().Repos.GetSelectedItems, + ), + c: c, + } +} + +func (self *ReposController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + return []*types.Binding{ + { + Keys: opts.GetKeys(opts.Config.Universal.Select), + Handler: self.withItem(self.enter), + GetDisabledReason: self.require(self.singleItemSelected()), + Description: self.c.Tr.Switch, + Tooltip: self.c.Tr.SwitchToRepoTooltip, + DisplayOnScreen: true, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.GoInto), + Handler: self.withItem(self.enter), + GetDisabledReason: self.require(self.singleItemSelected()), + }, + { + Keys: opts.GetKeys(opts.Config.Universal.Edit), + Handler: (&EditConfigAction{c: self.c}).Call, + Description: self.c.Tr.EditConfig, + Tooltip: self.c.Tr.EditFileTooltip, + DisplayOnScreen: true, + }, + { + Keys: opts.GetKeys(opts.Config.Status.CheckForUpdate), + Handler: self.c.Helpers().Update.CheckForUpdateInForeground, + Description: self.c.Tr.CheckForUpdate, + }, + } +} + +func (self *ReposController) GetOnRenderToMain() func() { + return func() { + var task types.UpdateTask + repo := self.context().GetSelected() + if repo == nil { + task = types.NewRenderStringTask(self.c.Tr.NoRepos) + } else { + cmdObj := self.c.OS().Cmd.New( + git_commands.NewGitCmd("status"). + Config("color.status=always"). + Dir(repo.Path). + ToArgv(), + ).DontLog() + task = types.NewRunCommandTask(git_commands.ForOtherRepo(cmdObj).GetCmd()) + } + + self.c.RenderToMainViews(types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Title: self.c.Tr.ReposTitle, + Task: task, + }, + }) + } +} + +func (self *ReposController) GetOnDoubleClick() func() error { + return self.withItemGraceful(self.enter) +} + +func (self *ReposController) enter(repo *models.Repo) error { + if presentation.IsCurrentRepo(repo, self.c.Git().RepoPaths.WorktreePath()) { + return nil + } + + if err := self.c.Helpers().Repos.SwitchToTopLevelRepo(repo.Path, context.REPOS_CONTEXT_KEY); err != nil { + return err + } + + // The switch focuses the new repo's repos context with its cursor at the + // top, so put the cursor back on the repo the user picked. + self.context().SelectRepo(repo.Path) + self.c.PostRefreshUpdate(self.context()) + return nil +} + +func (self *ReposController) context() *context.ReposContext { + return self.c.Contexts().Repos +} diff --git a/pkg/gui/dummies.go b/pkg/gui/dummies.go index 979f818e1a8..03c2b705b8c 100644 --- a/pkg/gui/dummies.go +++ b/pkg/gui/dummies.go @@ -17,6 +17,6 @@ func NewDummyUpdater() *updates.Updater { // NewDummyGui creates a new dummy GUI for testing func NewDummyGui() *Gui { newAppConfig := config.NewDummyAppConfig() - dummyGui, _ := NewGui(common.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{Major: 2, Minor: 0, Patch: 0}, NewDummyUpdater(), false, "", nil) + dummyGui, _ := NewGui(common.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{Major: 2, Minor: 0, Patch: 0}, NewDummyUpdater(), false, "", "", nil) return dummyGui } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 7659a5f0014..c69a554d891 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -121,6 +121,18 @@ type Gui struct { // the extras window contains things like the command log ShowExtrasWindow bool + // The repos of multi-repo mode. It lives here and not in GuiRepoState + // because it must survive switching between the repos it lists. + repoList []*models.Repo + + // Numbers the loads of repoList, so that a slow load can't overwrite a + // newer one. It lives here because the helpers that start a load are + // rebuilt on every repo switch. + repoListLoadSeq atomic.Int64 + + // The directory that multi-repo mode lists repos from. Empty when the mode is off. + multiRepoRoot string + PopupHandler types.IPopupHandler // Bumped every time we switch to a different repository (in resetState). @@ -202,6 +214,26 @@ func (self *StateAccessor) SetShowExtrasWindow(value bool) { self.gui.ShowExtrasWindow = value } +func (self *StateAccessor) GetRepoList() []*models.Repo { + return self.gui.repoList +} + +func (self *StateAccessor) NextRepoListLoadSeq() int64 { + return self.gui.repoListLoadSeq.Add(1) +} + +func (self *StateAccessor) IsLatestRepoListLoad(seq int64) bool { + return self.gui.repoListLoadSeq.Load() == seq +} + +func (self *StateAccessor) GetMultiRepoRoot() string { + return self.gui.multiRepoRoot +} + +func (self *StateAccessor) SetRepoList(repos []*models.Repo) { + self.gui.repoList = repos +} + func (self *StateAccessor) GetRetainOriginalDir() bool { return self.gui.RetainOriginalDir } @@ -506,6 +538,9 @@ func (gui *Gui) getPerRepoConfigFiles() []*config.ConfigFile { func (gui *Gui) onUserConfigLoaded() error { userConfig := gui.Config.GetUserConfig() + if gui.multiRepoRoot != "" { + userConfig.Gui.SidePanels = config.SidePanelsForMultiRepo(userConfig.Gui.SidePanels) + } gui.Common.SetUserConfig(userConfig) if gui.previousLanguageConfig != userConfig.Gui.Language { @@ -667,7 +702,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { gui.applySidePanelConfig() - return initialContext(contextTree, startArgs) + return initialContext(contextTree, startArgs, gui.multiRepoRoot != "") } func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { @@ -755,8 +790,11 @@ func parseScreenModeArg(screenModeArg string) types.ScreenMode { } } -func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.IListContext { +func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs, multiRepo bool) types.IListContext { var initialContext types.IListContext = contextTree.Files + if multiRepo { + initialContext = contextTree.Repos + } if startArgs.FilterPath != "" { initialContext = contextTree.LocalCommits @@ -791,6 +829,7 @@ func NewGui( updater *updates.Updater, showRecentRepos bool, initialDir string, + multiRepoRoot string, test integrationTypes.IntegrationTest, ) (*Gui, error) { gui := &Gui{ @@ -813,6 +852,7 @@ func NewGui( ShowExtrasWindow: true, InitialDir: initialDir, + multiRepoRoot: multiRepoRoot, afterLayoutFuncs: make(chan func() error, 1000), itemOperations: make(map[string]types.ItemOperation), @@ -1163,7 +1203,7 @@ func (gui *Gui) loadNewRepo() error { // the old repo's data while others already show the new one's data, so // update the UI only when everything is ready, and also block input to // prevent accidentally trying to act on the old, stale data. - options := types.RefreshOptions{DontBlockRepoSwitch: true} + options := types.RefreshOptions{Scope: helpers.DefaultScopesWithRepos(), DontBlockRepoSwitch: true} refresh := gui.c.Refresh if isFirstRefreshAfterStartup { isFirstRefreshAfterStartup = false diff --git a/pkg/gui/presentation/repos.go b/pkg/gui/presentation/repos.go new file mode 100644 index 00000000000..0209f6d43db --- /dev/null +++ b/pkg/gui/presentation/repos.go @@ -0,0 +1,34 @@ +package presentation + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/jesseduffield/lazygit/pkg/theme" + "github.com/samber/lo" +) + +func GetRepoListDisplayStrings(repos []*models.Repo, currentPath string) [][]string { + return lo.Map(repos, func(repo *models.Repo, _ int) []string { + current := "" + if IsCurrentRepo(repo, currentPath) { + current = " *" + } + branch := style.FgCyan.Sprint(repo.Branch) + if repo.Dirty { + branch += style.FgYellow.Sprint("*") + } + return []string{ + style.FgGreen.Sprint(current), + theme.DefaultTextColor.Sprint(repo.Name), + branch, + } + }) +} + +// IsCurrentRepo reports whether repo is the repo at currentPath. currentPath +// comes from git, which writes forward slashes on Windows too. +func IsCurrentRepo(repo *models.Repo, currentPath string) bool { + return repo.Path == filepath.Clean(filepath.FromSlash(currentPath)) +} diff --git a/pkg/gui/presentation/repos_test.go b/pkg/gui/presentation/repos_test.go new file mode 100644 index 00000000000..6f12675d283 --- /dev/null +++ b/pkg/gui/presentation/repos_test.go @@ -0,0 +1,41 @@ +package presentation + +import ( + "path/filepath" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestGetRepoListDisplayStrings(t *testing.T) { + repos := []*models.Repo{ + {Path: "/w/alpha", Name: "alpha", Branch: "feature"}, + {Path: "/w/beta", Name: "beta", Branch: "master", Dirty: true}, + } + + rows := GetRepoListDisplayStrings(repos, "/w/alpha") + + decolorised := lo.Map(rows, func(row []string, _ int) []string { + return lo.Map(row, func(cell string, _ int) string { return utils.Decolorise(cell) }) + }) + assert.Equal(t, [][]string{ + {" *", "alpha", "feature"}, + {"", "beta", "master*"}, + }, decolorised) +} + +func TestGetRepoListDisplayStringsGitStylePath(t *testing.T) { + repos := []*models.Repo{ + {Path: filepath.FromSlash("/w/alpha"), Name: "alpha", Branch: "feature"}, + {Path: filepath.FromSlash("/w/beta"), Name: "beta", Branch: "master"}, + } + + rows := GetRepoListDisplayStrings(repos, "/w/beta/") + + assert.Equal(t, []string{"", " *"}, lo.Map(rows, func(row []string, _ int) string { + return utils.Decolorise(row[0]) + })) +} diff --git a/pkg/gui/repo_list_seq_test.go b/pkg/gui/repo_list_seq_test.go new file mode 100644 index 00000000000..17343b97163 --- /dev/null +++ b/pkg/gui/repo_list_seq_test.go @@ -0,0 +1,21 @@ +package gui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Two accessors on one Gui stand for the helpers before and after a repo switch. +func TestRepoListLoadSeqSurvivesHelperRebuild(t *testing.T) { + g := &Gui{} + before := &StateAccessor{gui: g} + after := &StateAccessor{gui: g} + + oldSeq := before.NextRepoListLoadSeq() + newSeq := after.NextRepoListLoadSeq() + + assert.NotEqual(t, oldSeq, newSeq) + assert.False(t, before.IsLatestRepoListLoad(oldSeq)) + assert.True(t, before.IsLatestRepoListLoad(newSeq)) +} diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index 8b327b1970e..7a1abdec644 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -12,6 +12,7 @@ import ( // config.ValidSidePanelTabs (enforced by a test). var sidePanelViewNames = map[string]string{ "status": "status", + "repos": "repos", "files": "files", "worktrees": "worktrees", "submodules": "submodules", @@ -28,6 +29,7 @@ func (gui *Gui) sidePanelTabTitles() map[string]string { tr := gui.c.Tr return map[string]string{ "status": tr.StatusTitle, + "repos": tr.ReposTitle, "files": tr.FilesTitle, "worktrees": tr.WorktreesTitle, "submodules": tr.SubmodulesTitle, @@ -44,6 +46,7 @@ func (gui *Gui) sidePanelTabTitles() map[string]string { func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Context { return map[string]types.Context{ "status": contextTree.Status, + "repos": contextTree.Repos, "files": contextTree.Files, "worktrees": contextTree.Worktrees, "submodules": contextTree.Submodules, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6f77c78563f..b5c25dac7e1 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -440,6 +440,11 @@ type IStateAccessor interface { SetUpdating(bool) GetShowExtrasWindow() bool SetShowExtrasWindow(bool) + GetRepoList() []*models.Repo + SetRepoList(repos []*models.Repo) + NextRepoListLoadSeq() int64 + IsLatestRepoListLoad(seq int64) bool + GetMultiRepoRoot() string GetRetainOriginalDir() bool SetRetainOriginalDir(bool) GetItemOperation(item HasUrn) ItemOperation diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 76aa35d9b7f..9eec1282dca 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -16,6 +16,7 @@ const ( WORKTREES STATUS SUBMODULES + REPOS MERGE_CONFLICTS COMMIT_FILES // not actually views. Will refactor this later diff --git a/pkg/gui/types/views.go b/pkg/gui/types/views.go index 2dff6c2cb04..a14cfcc4e88 100644 --- a/pkg/gui/types/views.go +++ b/pkg/gui/types/views.go @@ -4,6 +4,7 @@ import "github.com/jesseduffield/lazygit/pkg/gocui" type Views struct { Status *gocui.View + Repos *gocui.View Submodules *gocui.View Files *gocui.View Branches *gocui.View diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 653c23c4b56..9cc9df97092 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -27,6 +27,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping { // first layer. Ordering within this layer does not matter because there are // no overlapping views {viewPtr: &gui.Views.Status, name: "status"}, + {viewPtr: &gui.Views.Repos, name: "repos"}, {viewPtr: &gui.Views.Snake, name: "snake"}, {viewPtr: &gui.Views.Submodules, name: "submodules"}, {viewPtr: &gui.Views.Worktrees, name: "worktrees"}, @@ -232,6 +233,7 @@ func (gui *Gui) configureViewProperties() { gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace gui.Views.Status.Title = gui.c.Tr.StatusTitle + gui.Views.Repos.Title = gui.c.Tr.ReposTitle gui.Views.CommitMessage.Title = gui.c.Tr.CommitSummary gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle gui.Views.Extras.Title = gui.c.Tr.CommandLog diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index efd84d722e5..94330321336 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -48,6 +48,8 @@ type TranslationSet struct { BaseCommitIsNotInCurrentView string HunksWithOnlyAddedLinesWarning string StatusTitle string + ReposTitle string + NoRepos string GlobalTitle string Execute string Stage string @@ -922,6 +924,7 @@ type TranslationSet struct { Switch string SwitchToWorktree string SwitchToWorktreeTooltip string + SwitchToRepoTooltip string AlreadyCheckedOutByWorktree string BranchCheckedOutByWorktree string SomeBranchesCheckedOutByWorktreeError string @@ -1233,6 +1236,8 @@ func EnglishTranslationSet() *TranslationSet { BaseCommitIsNotInCurrentView: "Base commit is not in current view", HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", StatusTitle: "Status", + ReposTitle: "Repositories", + NoRepos: "No repositories", Execute: "Execute", Stage: "Stage", StageTooltip: "Toggle staged for selected file.", @@ -2112,6 +2117,7 @@ func EnglishTranslationSet() *TranslationSet { Switch: "Switch", SwitchToWorktree: "Switch to worktree", SwitchToWorktreeTooltip: "Switch to the selected worktree.", + SwitchToRepoTooltip: "Switch to the selected repository.", AlreadyCheckedOutByWorktree: "This branch is checked out by worktree {{.worktreeName}}. Do you want to switch to that worktree?", BranchCheckedOutByWorktree: "Branch {{.branchName}} is checked out by worktree {{.worktreeName}}", SomeBranchesCheckedOutByWorktreeError: "Some of the selected branches are checked out by other worktrees. Select them one by one to delete them.", diff --git a/pkg/integration/components/views.go b/pkg/integration/components/views.go index e8cb1dc6171..a4194e6f20e 100644 --- a/pkg/integration/components/views.go +++ b/pkg/integration/components/views.go @@ -54,6 +54,10 @@ func (self *Views) Status() *ViewDriver { return self.regularView("status") } +func (self *Views) Repos() *ViewDriver { + return self.regularView("repos") +} + func (self *Views) Submodules() *ViewDriver { return self.regularView("submodules") } diff --git a/pkg/integration/tests/multi_repo/empty_dir_fallback.go b/pkg/integration/tests/multi_repo/empty_dir_fallback.go new file mode 100644 index 00000000000..1530501c640 --- /dev/null +++ b/pkg/integration/tests/multi_repo/empty_dir_fallback.go @@ -0,0 +1,26 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EmptyDirFallback = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start in a directory without repos and get the existing notARepository behavior", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().NotARepository = "create" + }, + SetupRepo: func(shell *Shell) { + shell.Chdir("..") + shell.CreateDir("empty") + shell.Chdir("empty") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused() + + t.Views().Status(). + Content(Contains("empty")) + }, +}) diff --git a/pkg/integration/tests/multi_repo/new_branch_updates_row.go b/pkg/integration/tests/multi_repo/new_branch_updates_row.go new file mode 100644 index 00000000000..14fd4d268d7 --- /dev/null +++ b/pkg/integration/tests/multi_repo/new_branch_updates_row.go @@ -0,0 +1,30 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NewBranchUpdatesRow = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Check out a new branch in the open repo and see its row in the repos panel follow", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: setupWorkspace, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Press(keys.Universal.New) + + t.ExpectPopup().Prompt(). + Title(Contains("New branch name")). + Type("topic"). + Confirm() + + t.Views().Repos(). + Lines( + Contains(" * alpha").Contains("topic"), + Contains("beta").Contains("master*"), + Contains("org/gamma").Contains("master"), + ) + }, +}) diff --git a/pkg/integration/tests/multi_repo/shared.go b/pkg/integration/tests/multi_repo/shared.go new file mode 100644 index 00000000000..5dbb2635c8a --- /dev/null +++ b/pkg/integration/tests/multi_repo/shared.go @@ -0,0 +1,39 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Git finds the lazygit source repo above the test directory unless the ceiling +// stops it, and then lazygit would never see a non-repo start directory. +var ceilingEnv = map[string]string{"GIT_CEILING_DIRECTORIES": "{{actualPath}}"} + +// setupWorkspace creates actual/workspace with three repos and leaves the shell +// in it, so that lazygit starts there. alpha is on branch feature, beta has an +// untracked file, and org/gamma is one level deeper. +func setupWorkspace(shell *components.Shell) { + shell.Chdir("..") + shell.CreateDir("workspace") + shell.Chdir("workspace") + + for _, name := range []string{"alpha", "beta", "org/gamma"} { + shell.CreateDir(name) + shell.Chdir(name) + shell.Init() + shell.EmptyCommit("initial") + if name == "alpha" { + shell.NewBranch("feature") + } + if name == "beta" { + shell.CreateFile("untracked.txt", "content") + } + shell.Chdir(relativeBack(name)) + } +} + +func relativeBack(name string) string { + if name == "org/gamma" { + return "../.." + } + return ".." +} diff --git a/pkg/integration/tests/multi_repo/skip_broken_repos.go b/pkg/integration/tests/multi_repo/skip_broken_repos.go new file mode 100644 index 00000000000..b44d7086fee --- /dev/null +++ b/pkg/integration/tests/multi_repo/skip_broken_repos.go @@ -0,0 +1,28 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SkipBrokenRepos = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start in a directory whose first repos are broken and enter the first usable one", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setupWorkspace(shell) + shell.CreateDir("aaa-broken/.git") + shell.CreateFile("aab-stale/.git", "gitdir: ../nowhere\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Repos(). + IsFocused(). + Lines( + DoesNotContain(" *").Contains("aaa-broken"), + DoesNotContain(" *").Contains("aab-stale"), + Contains(" * alpha").Contains("feature"), + DoesNotContain(" *").Contains("beta"), + DoesNotContain(" *").Contains("org/gamma"), + ) + }, +}) diff --git a/pkg/integration/tests/multi_repo/start_in_parent.go b/pkg/integration/tests/multi_repo/start_in_parent.go new file mode 100644 index 00000000000..bacec75f521 --- /dev/null +++ b/pkg/integration/tests/multi_repo/start_in_parent.go @@ -0,0 +1,25 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartInParent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start in a directory that holds several repos and see them listed with their status", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: setupWorkspace, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Repos(). + IsFocused(). + Lines( + Contains(" * alpha").Contains("feature").IsSelected(), + Contains("beta").Contains("master*"), + Contains("org/gamma").Contains("master"), + ) + + t.Views().Files(). + IsEmpty() + }, +}) diff --git a/pkg/integration/tests/multi_repo/start_in_symlinked_parent.go b/pkg/integration/tests/multi_repo/start_in_symlinked_parent.go new file mode 100644 index 00000000000..23685e20cfe --- /dev/null +++ b/pkg/integration/tests/multi_repo/start_in_symlinked_parent.go @@ -0,0 +1,27 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartInSymlinkedParent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start in a symlink to a directory of repos and see the current repo marked", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setupWorkspace(shell) + shell.Chdir("..") + shell.RunCommand([]string{"ln", "-s", "workspace", "link"}) + shell.Chdir("link") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Repos(). + IsFocused(). + Lines( + Contains(" * alpha").Contains("feature").IsSelected(), + DoesNotContain(" *").Contains("beta"), + DoesNotContain(" *").Contains("org/gamma"), + ) + }, +}) diff --git a/pkg/integration/tests/multi_repo/switch_repo.go b/pkg/integration/tests/multi_repo/switch_repo.go new file mode 100644 index 00000000000..258ed788199 --- /dev/null +++ b/pkg/integration/tests/multi_repo/switch_repo.go @@ -0,0 +1,32 @@ +package multi_repo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SwitchRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switch to another repo from the repos panel", + ExtraEnvVars: ceilingEnv, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: setupWorkspace, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Repos(). + IsFocused(). + NavigateToLine(Contains("beta")). + PressEnter() + + t.Views().Repos(). + IsFocused(). + Lines( + DoesNotContain(" *").Contains("alpha"), + Contains(" * beta").IsSelected(), + DoesNotContain(" *").Contains("org/gamma"), + ) + + t.Views().Files(). + Lines( + Contains("untracked.txt"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 54fe8f2b34c..8a622f99895 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -20,6 +20,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase" "github.com/jesseduffield/lazygit/pkg/integration/tests/main_view" "github.com/jesseduffield/lazygit/pkg/integration/tests/misc" + "github.com/jesseduffield/lazygit/pkg/integration/tests/multi_repo" "github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building" "github.com/jesseduffield/lazygit/pkg/integration/tests/reflog" "github.com/jesseduffield/lazygit/pkg/integration/tests/remote" @@ -511,6 +512,12 @@ var tests = []*components.IntegrationTest{ misc.RecentReposReftableRepo, misc.RecentReposWithLongNames, misc.StartInGitDir, + multi_repo.EmptyDirFallback, + multi_repo.NewBranchUpdatesRow, + multi_repo.SkipBrokenRepos, + multi_repo.StartInParent, + multi_repo.StartInSymlinkedParent, + multi_repo.SwitchRepo, patch_building.CopyRenamedFileDiff, patch_building.RenameSimilarityThresholdChange, patch_building.RenamedFileWhole, diff --git a/schema-master/config.json b/schema-master/config.json index 2607715b20e..03c9a9e36d0 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3996,6 +3996,19 @@ "type": "object", "description": "Config relating to merging" }, + "MultiRepoConfig": { + "properties": { + "maxDepth": { + "type": "integer", + "minimum": 1, + "description": "How many directory levels below the start directory lazygit searches for git repos. A value of 1 searches only the direct subdirectories.", + "default": 2 + } + }, + "additionalProperties": false, + "type": "object", + "description": "Config relating to multi-repo mode, which lazygit uses when you start it in a directory that is not a git repo but contains git repos" + }, "OSConfig": { "properties": { "edit": { @@ -4090,6 +4103,7 @@ "type": "string", "enum": [ "status", + "repos", "files", "worktrees", "submodules", @@ -4166,6 +4180,10 @@ "$ref": "#/$defs/WorktreeConfig", "description": "Config relating to git worktrees" }, + "multiRepo": { + "$ref": "#/$defs/MultiRepoConfig", + "description": "Config relating to multi-repo mode, which lazygit uses when you start it in a directory that is not a git repo but contains git repos" + }, "update": { "$ref": "#/$defs/UpdateConfig", "description": "Periodic update checks"