1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2026-06-20 01:19:23 +02:00

Show branch name and detached HEAD in worktrees tab (#5339)

### PR Description

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.
This commit is contained in:
Stefan Haller
2026-03-08 12:53:47 +01:00
committed by GitHub
16 changed files with 215 additions and 26 deletions
@@ -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/")
@@ -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 {
+3
View File
@@ -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
+6 -1
View File
@@ -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()
+15 -3
View File
@@ -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 ""
}
+111
View File
@@ -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)
})
}
}
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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(),
)
},
})
@@ -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)"),
)
},
})
@@ -48,7 +48,7 @@ var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Worktrees().
Focus().
Lines(
Contains("repo (main)").IsSelected(),
Contains("(main worktree)").IsSelected(),
Contains("linked-worktree"),
)
},
@@ -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().
@@ -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(),
)
},
})
@@ -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(),
)
},
})
@@ -59,7 +59,7 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Worktrees().
Focus().
Lines(
Contains("repo (main)").IsSelected(),
Contains("(main worktree)").IsSelected(),
)
},
})
@@ -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)
@@ -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)"),
)
},