From 727b4b56dd3db12180812c4b780c552cfeb0dbdf Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Wed, 4 Mar 2026 13:26:45 +0100 Subject: [PATCH] Show branch name and detached HEAD in worktrees tab The worktrees tab now displays the checked-out branch (or detached HEAD state) for each worktree, making it much easier to see which worktree holds which branch. This is useful when you need to "unlock" a branch that's occupied by another worktree: you can now clearly see which worktrees are on a branch vs detached, without having to select each one individually. Also rename the "(main)" label to "(main worktree)" to avoid confusion with the common "main" branch name, and move it after the branch column. --- pkg/commands/git_commands/worktree_loader.go | 2 + .../git_commands/worktree_loader_test.go | 56 +++++++++ pkg/commands/models/worktree.go | 3 + pkg/gui/controllers/worktrees_controller.go | 7 +- pkg/gui/presentation/worktrees.go | 18 ++- pkg/gui/presentation/worktrees_test.go | 111 ++++++++++++++++++ pkg/i18n/english.go | 2 +- pkg/integration/tests/worktree/crud.go | 10 +- .../tests/worktree/custom_command.go | 4 +- .../worktree/detach_worktree_from_branch.go | 2 +- .../worktree/exclude_file_in_worktree.go | 2 +- .../tests/worktree/force_remove_worktree.go | 4 +- .../force_remove_worktree_with_submodules.go | 4 +- .../worktree/remove_worktree_from_branch.go | 2 +- .../tests/worktree/reset_window_tabs.go | 4 +- .../tests/worktree/worktree_in_repo.go | 10 +- 16 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 pkg/gui/presentation/worktrees_test.go diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 42881f2fc..0e3615f13 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -67,6 +67,8 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { // we can parallelize the calls to git rev-parse GitDir: "", } + } else if strings.HasPrefix(splitLine, "HEAD ") { + current.Head = strings.SplitN(splitLine, " ", 2)[1] } else if strings.HasPrefix(splitLine, "branch ") { branch := strings.SplitN(splitLine, " ", 2)[1] current.Branch = strings.TrimPrefix(branch, "refs/heads/") diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 3d8c3ad39..1127540ca 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -46,6 +46,7 @@ branch refs/heads/mybranch IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", Name: "repo", }, }, @@ -86,6 +87,7 @@ branch refs/heads/mybranch-worktree IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", Name: "repo", }, { @@ -95,6 +97,7 @@ branch refs/heads/mybranch-worktree IsPathMissing: false, GitDir: "/path/to/repo/.git/worktrees/repo-worktree", Branch: "mybranch-worktree", + Head: "775955775e79b8f5b4c4b56f82fbf657e2d5e4de", Name: "repo-worktree", }, }, @@ -124,6 +127,7 @@ branch refs/heads/missingbranch IsPathMissing: true, GitDir: "", Branch: "missingbranch", + Head: "775955775e79b8f5b4c4b56f82fbf657e2d5e4de", Name: "worktree", }, }, @@ -164,6 +168,7 @@ branch refs/heads/mybranch-worktree IsPathMissing: false, GitDir: "/path/to/repo/.git/worktrees/repo-worktree", Branch: "mybranch-worktree", + Head: "775955775e79b8f5b4c4b56f82fbf657e2d5e4de", Name: "repo-worktree", }, { @@ -173,11 +178,62 @@ branch refs/heads/mybranch-worktree IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", Name: "repo", }, }, expectedErr: "", }, + { + testName: "Detached HEAD worktree", + repoPaths: &RepoPaths{ + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + }, + before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { + runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, + `worktree /path/to/repo +HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d +branch refs/heads/mybranch + +worktree /path/to/repo-worktree +HEAD 775955775e79b8f5b4c4b56f82fbf657e2d5e4de +detached +`, + nil) + gitArgsMainWorktree := append(append([]string{"-C", "/path/to/repo"}, getRevParseArgs()...), "--absolute-git-dir") + runner.ExpectGitArgs(gitArgsMainWorktree, "/path/to/repo/.git", nil) + gitArgsLinkedWorktree := append(append([]string{"-C", "/path/to/repo-worktree"}, getRevParseArgs()...), "--absolute-git-dir") + runner.ExpectGitArgs(gitArgsLinkedWorktree, "/path/to/repo/.git/worktrees/repo-worktree", nil) + + _ = fs.MkdirAll("/path/to/repo/.git", 0o755) + _ = fs.MkdirAll("/path/to/repo-worktree", 0o755) + _ = fs.MkdirAll("/path/to/repo/.git/worktrees/repo-worktree", 0o755) + }, + expectedWorktrees: []*models.Worktree{ + { + IsMain: true, + IsCurrent: true, + Path: "/path/to/repo", + IsPathMissing: false, + GitDir: "/path/to/repo/.git", + Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + Name: "repo", + }, + { + IsMain: false, + IsCurrent: false, + Path: "/path/to/repo-worktree", + IsPathMissing: false, + GitDir: "/path/to/repo/.git/worktrees/repo-worktree", + Branch: "", + Head: "775955775e79b8f5b4c4b56f82fbf657e2d5e4de", + Name: "repo-worktree", + }, + }, + expectedErr: "", + }, } for _, s := range scenarios { diff --git a/pkg/commands/models/worktree.go b/pkg/commands/models/worktree.go index 58d4bf790..053a3f101 100644 --- a/pkg/commands/models/worktree.go +++ b/pkg/commands/models/worktree.go @@ -19,6 +19,9 @@ type Worktree struct { // * the worktree is mid-rebase on the branch // * the worktree is mid-bisect on the branch Branch string + // The HEAD sha of the worktree. Always populated (even when Branch is set). + // Used for display when Branch is empty (detached HEAD state). + Head string // based on the path, but uniquified. Not the same name that git uses in the worktrees/ folder (no good reason for this, // I just prefer my naming convention better) Name string diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index d3f44e4ad..b861d6b04 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -10,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" ) type WorktreesController struct { @@ -95,7 +96,11 @@ func (self *WorktreesController) GetOnRenderToMain() func() { var builder strings.Builder w := tabwriter.NewWriter(&builder, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Name, style.FgGreen.Sprint(worktree.Name), main) - _, _ = fmt.Fprintf(w, "%s:\t%s\n", self.c.Tr.Branch, style.FgYellow.Sprint(worktree.Branch)) + branch := style.FgYellow.Sprint(worktree.Branch) + if worktree.Branch == "" && worktree.Head != "" { + branch = style.FgYellow.Sprintf("HEAD detached at %s", utils.ShortHash(worktree.Head)) + } + _, _ = fmt.Fprintf(w, "%s:\t%s\n", self.c.Tr.Branch, branch) _, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Path, style.FgCyan.Sprint(worktree.Path), missing) _ = w.Flush() diff --git a/pkg/gui/presentation/worktrees.go b/pkg/gui/presentation/worktrees.go index d2796af83..b2d99cb95 100644 --- a/pkg/gui/presentation/worktrees.go +++ b/pkg/gui/presentation/worktrees.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -40,12 +41,23 @@ func GetWorktreeDisplayString(tr *i18n.TranslationSet, worktree *models.Worktree } name := worktree.Name - if worktree.IsMain { - name += " " + tr.MainWorktree - } if worktree.IsPathMissing && !icons.IsIconEnabled() { name += " " + tr.MissingWorktree } res = append(res, textStyle.Sprint(name)) + var branch string + if worktree.Branch != "" { + branch = style.FgCyan.Sprint(worktree.Branch) + } else if worktree.Head != "" { + branch = style.FgYellow.Sprint("HEAD detached at " + utils.ShortHash(worktree.Head)) + } + res = append(res, branch+mainWorktreeLabel(tr, worktree)) return res } + +func mainWorktreeLabel(tr *i18n.TranslationSet, worktree *models.Worktree) string { + if worktree.IsMain { + return style.FgDefault.Sprint(" " + tr.MainWorktree) + } + return "" +} diff --git a/pkg/gui/presentation/worktrees_test.go b/pkg/gui/presentation/worktrees_test.go new file mode 100644 index 000000000..73abe100d --- /dev/null +++ b/pkg/gui/presentation/worktrees_test.go @@ -0,0 +1,111 @@ +package presentation + +import ( + "testing" + + "github.com/gookit/color" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/stretchr/testify/assert" + "github.com/xo/terminfo" +) + +func Test_GetWorktreeDisplayString(t *testing.T) { + tr := &i18n.TranslationSet{ + MainWorktree: "(main worktree)", + MissingWorktree: "(missing)", + } + + scenarios := []struct { + testName string + worktree *models.Worktree + expected []string + }{ + { + testName: "Current worktree on a branch", + worktree: &models.Worktree{ + Name: "my-worktree", + Branch: "my-branch", + IsCurrent: true, + }, + expected: []string{" *", "my-worktree", "my-branch"}, + }, + { + testName: "Non-current worktree on a branch", + worktree: &models.Worktree{ + Name: "my-worktree", + Branch: "feature-branch", + }, + expected: []string{"", "my-worktree", "feature-branch"}, + }, + { + testName: "Main worktree on a branch", + worktree: &models.Worktree{ + Name: "repo", + Branch: "main", + IsMain: true, + }, + expected: []string{"", "repo", "main (main worktree)"}, + }, + { + testName: "Detached HEAD worktree", + worktree: &models.Worktree{ + Name: "my-worktree", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + }, + expected: []string{"", "my-worktree", "HEAD detached at d85cc9d2"}, + }, + { + testName: "Detached HEAD on main worktree", + worktree: &models.Worktree{ + Name: "repo", + IsMain: true, + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + }, + expected: []string{"", "repo", "HEAD detached at d85cc9d2 (main worktree)"}, + }, + { + testName: "Worktree with branch takes precedence over head", + worktree: &models.Worktree{ + Name: "my-worktree", + Branch: "my-branch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + }, + expected: []string{"", "my-worktree", "my-branch"}, + }, + { + testName: "Missing worktree", + worktree: &models.Worktree{ + Name: "my-worktree", + IsPathMissing: true, + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + }, + expected: []string{"", "my-worktree (missing)", "HEAD detached at d85cc9d2"}, + }, + { + testName: "Worktree with no branch and no head", + worktree: &models.Worktree{ + Name: "my-worktree", + }, + expected: []string{"", "my-worktree", ""}, + }, + { + testName: "Main worktree with no branch and no head", + worktree: &models.Worktree{ + Name: "my-worktree", + IsMain: true, + }, + expected: []string{"", "my-worktree", " (main worktree)"}, + }, + } + + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) + defer color.ForceSetColorLevel(oldColorLevel) + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + result := GetWorktreeDisplayString(tr, s.worktree) + assert.Equal(t, s.expected, result) + }) + } +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index cf70b3e0b..0cb7d4a00 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1988,7 +1988,7 @@ func EnglishTranslationSet() *TranslationSet { CantDeleteMainWorktree: "You cannot remove the main worktree!", NoWorktreesThisRepo: "No worktrees", MissingWorktree: "(missing)", - MainWorktree: "(main)", + MainWorktree: "(main worktree)", NewWorktree: "New worktree", NewWorktreePath: "New worktree path", NewWorktreeBase: "New worktree base ref", diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index 504d47b72..cd539d10b 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -29,7 +29,7 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)"), + Contains("(main worktree)"), ). Press(keys.Universal.New). Tap(func() { @@ -55,7 +55,7 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("linked-worktree").IsSelected(), - Contains("repo (main)"), + Contains("(main worktree)"), ). // confirm we're still in the same view IsFocused() @@ -82,7 +82,7 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). // confirm we cannot remove the main worktree - NavigateToLine(Contains("repo (main)")). + NavigateToLine(Contains("(main worktree)")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Alert(). @@ -93,7 +93,7 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ // switch back to main worktree Press(keys.Universal.Select). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ) @@ -114,7 +114,7 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/worktree/custom_command.go b/pkg/integration/tests/worktree/custom_command.go index c10ed6749..00c3c4c06 100644 --- a/pkg/integration/tests/worktree/custom_command.go +++ b/pkg/integration/tests/worktree/custom_command.go @@ -28,13 +28,13 @@ var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)"), + Contains("(main worktree)"), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). Press("d"). Lines( - Contains("repo (main)"), + Contains("(main worktree)"), ) }, }) diff --git a/pkg/integration/tests/worktree/detach_worktree_from_branch.go b/pkg/integration/tests/worktree/detach_worktree_from_branch.go index f6724ef3a..7b9430aa6 100644 --- a/pkg/integration/tests/worktree/detach_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/detach_worktree_from_branch.go @@ -48,7 +48,7 @@ var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ) }, diff --git a/pkg/integration/tests/worktree/exclude_file_in_worktree.go b/pkg/integration/tests/worktree/exclude_file_in_worktree.go index ba3f9e5a5..21b5c4948 100644 --- a/pkg/integration/tests/worktree/exclude_file_in_worktree.go +++ b/pkg/integration/tests/worktree/exclude_file_in_worktree.go @@ -19,7 +19,7 @@ var ExcludeFileInWorktree = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ). SelectNextItem(). diff --git a/pkg/integration/tests/worktree/force_remove_worktree.go b/pkg/integration/tests/worktree/force_remove_worktree.go index e716b4e2e..cde9e9da3 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree.go +++ b/pkg/integration/tests/worktree/force_remove_worktree.go @@ -23,7 +23,7 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). @@ -40,7 +40,7 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go index dd54eb97c..4af533e02 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go +++ b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go @@ -23,7 +23,7 @@ var ForceRemoveWorktreeWithSubmodules = NewIntegrationTest(NewIntegrationTestArg t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). @@ -40,7 +40,7 @@ var ForceRemoveWorktreeWithSubmodules = NewIntegrationTest(NewIntegrationTestArg Confirm() }). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/worktree/remove_worktree_from_branch.go b/pkg/integration/tests/worktree/remove_worktree_from_branch.go index b1e8cc1ce..f7f5d01cf 100644 --- a/pkg/integration/tests/worktree/remove_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/remove_worktree_from_branch.go @@ -59,7 +59,7 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/worktree/reset_window_tabs.go b/pkg/integration/tests/worktree/reset_window_tabs.go index 12c4526eb..c6a6ef464 100644 --- a/pkg/integration/tests/worktree/reset_window_tabs.go +++ b/pkg/integration/tests/worktree/reset_window_tabs.go @@ -34,14 +34,14 @@ var ResetWindowTabs = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Select). Lines( Contains("linked-worktree").IsSelected(), - Contains("repo (main)"), + Contains("(main worktree)"), ). // navigate back to the branches window Press(keys.Universal.NextBlock) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index f91dc13cc..5f0ee66ec 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -24,7 +24,7 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)"), + Contains("(main worktree)"), ). Press(keys.Universal.New). Tap(func() { @@ -50,13 +50,13 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("linked-worktree").IsSelected(), - Contains("repo (main)"), + Contains("(main worktree)"), ). // switch back to main worktree - NavigateToLine(Contains("repo (main)")). + NavigateToLine(Contains("(main worktree)")). Press(keys.Universal.Select). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree"), ) @@ -78,7 +78,7 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Worktrees(). Focus(). Lines( - Contains("repo (main)").IsSelected(), + Contains("(main worktree)").IsSelected(), Contains("linked-worktree (missing)"), ) },