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)"), ) },