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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)"),
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user