From d0fa50d4ef00a9ac0227ab729a863ab54bd285b5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 8 Sep 2026 09:13:00 +0200 Subject: [PATCH 01/16] Give popup panels their full width whenever the window has room for it The width of a popup panel was a ratio of the window's width, capped at the panel's maximum. The ratio never did anything for confirmations and prompts, whose minimum and maximum widths are both 80; they always came out at 80, or at the window width if that was narrower. For menus and the commit message editor it meant that the window had to be 158 columns wide before either of them reached its maximum width, and that below 140 columns they were 80 columns wide. This is narrower than the window has room for, and too narrow for the three columns of the recent repos menu. Give every panel the width it asks for as long as it fits, and shrink it only when the window leaves no choice. A panel keeps a margin of three columns on either side while it can afford to, so that it doesn't sit flush against the sides of the window as soon as the window gets a little narrow. Co-authored-by: Claude Opus 5 (1M context) --- .../helpers/confirmation_helper.go | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index f525c1d8a..61499af98 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -127,18 +127,28 @@ func (self *ConfirmationHelper) getPopupPanelDimensionsAux(contentWidth int, con height/2 + panelHeight/2 - 1 } +const ( + // The width a popup panel keeps as long as it fits into the window at all, + // even when the panel asks for less. + popupPanelMinWidth = 80 + + // The margin we try to leave between a popup panel and the sides of the + // window, so that the panel doesn't sit flush against them as soon as the + // window gets a little narrow. + popupPanelMargin = 3 +) + // Returns the outer width of the view, including its frame. To decide how to wrap text, subtract 2. // Also, note that X1-X0 of the view is one less than this. -func (self *ConfirmationHelper) getPopupPanelWidth(maxWidth int) int { - width, _ := self.c.GocuiGui().Size() - // we want a minimum width up to a point, then we do it based on ratio, but only up to the given max width - panelWidth := min(4*width/7, maxWidth) - minWidth := 80 - if panelWidth < minWidth { - panelWidth = min(width-2, minWidth) - } +func (self *ConfirmationHelper) getPopupPanelWidth(requestedWidth int) int { + windowWidth, _ := self.c.GocuiGui().Size() + // A panel gets the width it asks for as long as the margin fits beside it. + // It gives the margin up before it goes below the minimum width, and a + // column on either side is all it leaves in the end. + widthWithMargin := windowWidth - 2*popupPanelMargin + widthAtMinWidth := min(popupPanelMinWidth, windowWidth-2) - return panelWidth + return min(requestedWidth, max(widthWithMargin, widthAtMinWidth)) } func (self *ConfirmationHelper) prepareConfirmationPanel( From 7253a45c216920796948ca2e8359ab9451bf011b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 8 Sep 2026 09:51:57 +0200 Subject: [PATCH 02/16] Let a popup panel have an odd width The left and the right edge of a popup panel were each derived by halving the panel's width, so a panel that asked for an odd width lost a column and came out one column left of centre. Resizing the window then moved the panel's right edge only every other column, and left a gap of one column between the panel and where it should end half of the time. Derive the right edge from the left one and the width instead, the way the branch above already does it for a popup that has a parent. This also means that a panel of an odd width is now as wide as the width its text was wrapped to. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/confirmation_helper.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 61499af98..ca25204a6 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -118,13 +118,12 @@ func (self *ConfirmationHelper) getPopupPanelDimensionsAux(contentWidth int, con y0 += 1 return x0, y0, x0 + panelWidth - 1, y0 + panelHeight - 1 } - return width/2 - panelWidth/2, - height/2 - panelHeight/2 - panelHeight%2, - // Currently, X1/Y1 of a gocui view is one less than you would expect based on its - // width/height, so we need to subtract 1 here. See - // https://github.com/jesseduffield/lazygit/commit/f6f2a52dee8bba3ebd7e3b34b4b7c7d3e3795f3e - width/2 + panelWidth/2 - 1, - height/2 + panelHeight/2 - 1 + x0 := (width - panelWidth) / 2 + y0 := height/2 - panelHeight/2 - panelHeight%2 + // Currently, X1/Y1 of a gocui view is one less than you would expect based on its + // width/height, so we need to subtract 1 here. See + // https://github.com/jesseduffield/lazygit/commit/f6f2a52dee8bba3ebd7e3b34b4b7c7d3e3795f3e + return x0, y0, x0 + panelWidth - 1, y0 + panelHeight - 1 } const ( From b37018a9369bca027ad2bb321e87360d3977244b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 18:25:04 +0200 Subject: [PATCH 03/16] Collect the current branches of the recent repos in a slice Nothing needs to look up the branch of a repo by its path, so a slice indexed like the list of paths does the job, and its elements are plain strings instead of the values of type "any" that a sync.Map hands back. The next commits measure and truncate the branch name, which needs a string. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index c8a33bcbe..1fb8588e7 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -2,7 +2,6 @@ package helpers import ( "errors" - "fmt" "os" "path/filepath" "strings" @@ -112,24 +111,24 @@ func (self *ReposHelper) CreateRecentReposMenu() error { recentRepoPaths = self.c.GetAppState().RecentRepos[1:] } - currentBranches := sync.Map{} + currentBranches := make([]string, len(recentRepoPaths)) wg := sync.WaitGroup{} wg.Add(len(recentRepoPaths)) - for _, path := range recentRepoPaths { - go func(path string) { + for i, path := range recentRepoPaths { + go func() { defer wg.Done() - currentBranches.Store(path, self.getCurrentBranch(path)) - }(path) + currentBranches[i] = self.getCurrentBranch(path) + }() } wg.Wait() - menuItems := lo.Map(recentRepoPaths, func(path string, _ int) *types.MenuItem { - branchName, _ := currentBranches.Load(path) + menuItems := lo.Map(recentRepoPaths, func(path string, i int) *types.MenuItem { + branchName := currentBranches[i] if icons.IsIconEnabled() { - branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName) + branchName = icons.BRANCH_ICON + " " + branchName } return &types.MenuItem{ From b11651820aec6845d121d7b770f757462a3c5d44 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 18:29:28 +0200 Subject: [PATCH 04/16] Show the containing directory instead of the full path in the recent repos menu The third column of the recent repos menu spells out the full path of each repo. That repeats the directory name which the first column already shows, and it writes out the home directory in full. Both are wasted width in a menu that is limited to 90 columns; the path column is the first thing to run off the right edge, and users who don't know that 'L' scrolls the menu horizontally never see it at all. Show the directory that contains the repo instead, with the home directory abbreviated to '~'. For a list of 106 recent repos this takes the column from 111 characters down to 97 at its longest, and from 47 down to 28 in the median. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 5 ++++- pkg/utils/utils.go | 21 ++++++++++++++++++ pkg/utils/utils_test.go | 24 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 1fb8588e7..c280d7153 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -135,7 +135,10 @@ func (self *ReposHelper) CreateRecentReposMenu() error { LabelColumns: []string{ filepath.Base(path), style.FgCyan.Sprint(branchName), - style.FgMagenta.Sprint(path), + // The last segment of the path is already in the first column, + // so showing the directory that contains the repo is enough to + // tell repos with the same name apart. + style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))), }, OnPress: func() error { // Check before clearing the stack, so a refused switch doesn't diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0494bb035..d706fe719 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -120,3 +120,24 @@ func ExpandTilde(path string) string { } return filepath.Join(home, path[2:]) } + +// ContractTilde is the inverse of ExpandTilde: it replaces the current user's +// home directory at the start of a path with "~", so that paths can be shown +// in a shorter form. Paths outside the home directory are left untouched, as +// is the path if the home directory can't be determined. +func ContractTilde(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + + if path == home { + return "~" + } + + if rest, found := strings.CutPrefix(path, home+string(filepath.Separator)); found { + return "~" + string(filepath.Separator) + rest + } + + return path +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 8304e7ba4..4d51575fc 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -125,3 +125,27 @@ func TestExpandTilde(t *testing.T) { }) } } + +func TestContractTilde(t *testing.T) { + home, err := os.UserHomeDir() + assert.NoError(t, err) + + scenarios := []struct { + name string + path string + expected string + }{ + {"home directory", home, "~"}, + {"path inside the home directory", filepath.Join(home, "worktrees"), filepath.Join("~", "worktrees")}, + {"path outside the home directory is untouched", filepath.Join("/absolute", "path"), filepath.Join("/absolute", "path")}, + {"path merely starting with the home directory's name is untouched", home + "-backup", home + "-backup"}, + {"relative path is untouched", filepath.Join("relative", "path"), filepath.Join("relative", "path")}, + {"empty string is untouched", "", ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, ContractTilde(s.path)) + }) + } +} From fd5c703e1e62c51d2b5dc1f7477c41a78490fcd1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 18:38:57 +0200 Subject: [PATCH 05/16] Truncate the repo and branch names in the recent repos menu Each column of a menu is padded to the width of its widest entry, so a single long name in the first two columns of the recent repos menu pushes the path column off the right edge for every entry. Anyone whose worktree directories are named after their branches hits this: with a 90 column menu and one 42 character name, the path column starts at column 86 of 88. Truncate both names to 30 characters, and put the ones that got truncated into the item's tooltip, so that the full text is still on screen for the selected entry. Filtering keeps matching the full names and the full path, which the columns no longer show in their entirety. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/menu_context.go | 4 + pkg/gui/controllers/helpers/repos_helper.go | 98 ++++++++++++++----- pkg/gui/types/common.go | 6 ++ pkg/i18n/english.go | 4 + .../misc/recent_repos_with_long_names.go | 46 +++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 pkg/integration/tests/misc/recent_repos_with_long_names.go diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 55a3e5bfa..aa678f263 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -89,6 +89,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { }) } + if item.FilterColumns != nil { + return item.FilterColumns + } + return item.LabelColumns }, ) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index c280d7153..3821c5768 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -103,6 +103,76 @@ func (self *ReposHelper) getCurrentBranch(path string) string { return self.c.Tr.BranchUnknown } +// The maximum width of the repo name and branch name columns of the recent +// repos menu. Without a limit, one long name pushes the path column off the +// right edge of the menu for every entry, because each column is padded to the +// width of its widest entry. +const recentReposColumnMaxWidth = 30 + +func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *types.MenuItem { + repoName := filepath.Base(path) + displayedRepoName := utils.TruncateWithEllipsis(repoName, recentReposColumnMaxWidth) + + // The icon is part of the column, so it counts towards the maximum width. + branchColumn := branchName + if icons.IsIconEnabled() { + branchColumn = icons.BRANCH_ICON + " " + branchName + } + displayedBranchColumn := utils.TruncateWithEllipsis(branchColumn, recentReposColumnMaxWidth) + + // Spell out whatever the columns show in truncated form + type tooltipField struct { + label string + value string + } + fields := []tooltipField{} + addTooltipField := func(label string, value string) { + fields = append(fields, tooltipField{label: label, value: value}) + } + + if displayedRepoName != repoName { + addTooltipField(self.c.Tr.RecentReposRepoLabel, repoName) + } + if displayedBranchColumn != branchColumn { + addTooltipField(self.c.Tr.RecentReposBranchLabel, branchName) + } + + // Line the values up behind the widest of the labels that are there + labelWidth := utils.MaxFn(fields, func(field tooltipField) int { + return utils.StringWidth(field.label) + }) + tooltipLines := lo.Map(fields, func(field tooltipField, _ int) string { + return utils.WithPadding(field.label, labelWidth, utils.AlignLeft) + " " + field.value + }) + + return &types.MenuItem{ + LabelColumns: []string{ + displayedRepoName, + style.FgCyan.Sprint(displayedBranchColumn), + // The last segment of the path is already in the first column, so + // showing the directory that contains the repo is enough to tell + // repos with the same name apart. + style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))), + }, + // Filtering matches the full text, including the parts that the columns + // above truncate or leave out. + FilterColumns: []string{repoName, branchName, 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(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) + }, + } +} + func (self *ReposHelper) CreateRecentReposMenu() error { // we'll show an empty panel if there are no recent repos recentRepoPaths := []string{} @@ -126,33 +196,7 @@ func (self *ReposHelper) CreateRecentReposMenu() error { wg.Wait() menuItems := lo.Map(recentRepoPaths, func(path string, i int) *types.MenuItem { - branchName := currentBranches[i] - if icons.IsIconEnabled() { - branchName = icons.BRANCH_ICON + " " + branchName - } - - return &types.MenuItem{ - LabelColumns: []string{ - filepath.Base(path), - style.FgCyan.Sprint(branchName), - // The last segment of the path is already in the first column, - // so showing the directory that contains the repo is enough to - // tell repos with the same name apart. - style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))), - }, - 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(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) - }, - } + return self.recentRepoMenuItem(path, currentBranches[i]) }) return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 58ccf6d60..1603df9bb 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -310,6 +310,12 @@ type MenuItem struct { // alternative to Label. Allows specifying columns which will be auto-aligned LabelColumns []string + // The strings that filtering the menu matches against, for menus that + // abbreviate their columns to keep them narrow. If nil, LabelColumns are + // matched, so that a menu only needs to set this if what it displays is not + // the full text. + FilterColumns []string + OnPress func() error // Only applies when Label is used diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9c72b53df..918247902 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -332,6 +332,8 @@ type TranslationSet struct { NotMidRebase string MustSelectFixupCommit string RecentRepos string + RecentReposRepoLabel string + RecentReposBranchLabel string MergeOptionsTitle string RebaseOptionsTitle string CherryPickOptionsTitle string @@ -1490,6 +1492,8 @@ func EnglishTranslationSet() *TranslationSet { NotMidRebase: "This action only works during an interactive rebase", MustSelectFixupCommit: "This action only works on fixup commits", RecentRepos: "Recent repositories", + RecentReposRepoLabel: "Repo:", + RecentReposBranchLabel: "Branch:", MergeOptionsTitle: "Merge options", RebaseOptionsTitle: "Rebase options", CherryPickOptionsTitle: "Cherry-pick options", diff --git a/pkg/integration/tests/misc/recent_repos_with_long_names.go b/pkg/integration/tests/misc/recent_repos_with_long_names.go new file mode 100644 index 000000000..7396faff3 --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_with_long_names.go @@ -0,0 +1,46 @@ +package misc + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RecentReposWithLongNames = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Long repo and branch names are truncated in the recent repositories menu, and shown in full in the tooltip", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "SHOW_RECENT_REPOS": "true", + }, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // the first entry is the repo we're in, so it isn't offered + current, _ := filepath.Abs(".") + target, _ := filepath.Abs("../repo-with-a-name-that-is-far-too-long") + cfg.GetAppState().RecentRepos = []string{current, target} + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.NewBranch("branch-with-a-name-that-is-too-long") + shell.CloneNonBare("repo-with-a-name-that-is-far-too-long") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.ExpectPopup().Menu(). + Title(Equals("Recent repositories")). + Lines( + Contains("repo-with-a-name-that-is-far-… branch-with-a-name-that-is-to…").IsSelected(), + Contains("Cancel"), + ). + Tooltip(Equals("Repo: repo-with-a-name-that-is-far-too-long\nBranch: branch-with-a-name-that-is-too-long")). + // Filtering matches the full names, including the part that the + // menu truncates + Filter("too-long"). + Lines( + Contains("repo-with-a-name-that-is-far-…").IsSelected(), + ). + Confirm() + + t.Views().Status().Content(Contains("repo-with-a-name")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a4e732cf0..6dcb226f1 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -366,6 +366,7 @@ var tests = []*components.IntegrationTest{ misc.FilterRecentRepos, misc.InitialOpen, misc.RecentReposOnLaunch, + misc.RecentReposWithLongNames, misc.StartInGitDir, patch_building.Apply, patch_building.ApplyInReverse, From 9f7117398ae6bbd98f30cf2b8f83d90e98421425 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 19:01:55 +0200 Subject: [PATCH 06/16] Add a truncation function that puts the ellipsis in the middle TruncateWithEllipsis cuts off the end of a string, which is the wrong end for a path: what distinguishes two paths is often the last segment, and it is the one thing the reader wants to see. Keep both ends and put the ellipsis between them. Co-authored-by: Claude Opus 5 (1M context) --- pkg/utils/formatting.go | 43 ++++++++++++++++++++++++++++++++++++ pkg/utils/formatting_test.go | 32 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/pkg/utils/formatting.go b/pkg/utils/formatting.go index f058f4f40..080f58f87 100644 --- a/pkg/utils/formatting.go +++ b/pkg/utils/formatting.go @@ -199,6 +199,49 @@ func TruncateWithEllipsis(str string, limit int) string { return truncatedStr + "…" } +// TruncateWithEllipsisInMiddle returns a string, truncated to a certain width, +// with an ellipsis in the middle. Use it where the end of the string is as +// informative as its beginning, e.g. for paths. +func TruncateWithEllipsisInMiddle(str string, limit int) string { + if StringWidth(str) <= limit { + return str + } + if limit <= 2 { + return strings.Repeat(".", limit) + } + + clusters := []string{} + widths := []int{} + graphemes := uniseg.NewGraphemes(str) + for graphemes.Next() { + clusters = append(clusters, graphemes.Str()) + widths = append(widths, graphemes.Width()) + } + + // One column goes to the ellipsis; the rest is split between the two ends, + // with the odd one going to the front. + remaining := limit - 1 + frontLimit := (remaining + 1) / 2 + + front := 0 + frontWidth := 0 + for front < len(clusters) && frontWidth+widths[front] <= frontLimit { + frontWidth += widths[front] + front++ + } + + // Whatever the front didn't use, e.g. because a wide grapheme didn't fit + // into it, is available to the back. + back := len(clusters) + backWidth := 0 + for back > front && backWidth+widths[back-1] <= remaining-frontWidth { + backWidth += widths[back-1] + back-- + } + + return strings.Join(clusters[:front], "") + "…" + strings.Join(clusters[back:], "") +} + func SafeTruncate(str string, limit int) string { if len(str) > limit { return str[0:limit] diff --git a/pkg/utils/formatting_test.go b/pkg/utils/formatting_test.go index bc30fcf25..7de0492ae 100644 --- a/pkg/utils/formatting_test.go +++ b/pkg/utils/formatting_test.go @@ -162,6 +162,38 @@ func TestTruncateWithEllipsis(t *testing.T) { } } +func TestTruncateWithEllipsisInMiddle(t *testing.T) { + type scenario struct { + str string + limit int + expected string + } + + scenarios := []scenario{ + {"hello world !", 0, ""}, + {"hello world !", 1, "."}, + {"hello world !", 2, ".."}, + {"hello world !", 3, "h…!"}, + {"hello world !", 4, "he…!"}, + {"hello world !", 5, "he… !"}, + {"hello world !", 12, "hello …rld !"}, + {"hello world !", 13, "hello world !"}, + {"hello world !", 14, "hello world !"}, + // A wide grapheme that doesn't fit into the front leaves its column to + // the back + {"大大大大", 5, "大…大"}, + {"大大大大", 7, "大…大大"}, + {"大大大大", 8, "大大大大"}, + {"大大大大", 2, ".."}, + {"大大大大", 1, "."}, + {"大大大大", 0, ""}, + } + + for _, s := range scenarios { + assert.EqualValues(t, s.expected, TruncateWithEllipsisInMiddle(s.str, s.limit)) + } +} + func TestRenderDisplayStrings(t *testing.T) { type scenario struct { input [][]string From 49c267d0da2416a2b48192e4d51fddcf0c5c8d37 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 19:02:48 +0200 Subject: [PATCH 07/16] Give the maximum width of a menu a name The recent repos menu is about to size its columns to fit into a menu of that width. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/confirmation_helper.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index ca25204a6..6bfd7ca4e 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -333,6 +333,10 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() { } } +// The width a menu grows to when the window is wide enough for it. Its content +// is two columns narrower than this, for the frame. +const menuMaxWidth = 90 + // The rows that a filter row adds to a menu popup: one for the input, and one // for its bottom border. Its top border is the menu's bottom border. const menuFilterRowHeight = 2 @@ -368,7 +372,7 @@ func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) { // resize the window itemCount := menuContext.UnfilteredLen() offset := 3 - panelWidth := self.getPopupPanelWidth(90) + panelWidth := self.getPopupPanelWidth(menuMaxWidth) contentWidth := panelWidth - 2 // minus 2 for the frame promptLinesCount := self.layoutMenuPrompt(contentWidth) // The row is reserved for the whole time the menu is open, even though it only From 3452d41fe22f742d7014bb6490efb42581fc84fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 19:08:19 +0200 Subject: [PATCH 08/16] Truncate the path column of the recent repos menu too The path column is the last one, so nothing pushes it aside; instead it runs off the right edge of the menu itself, and the reader has no way of telling that there is more to it. Give it a maximum width as well, sized so that the three columns and the spaces between them fill the menu at its widest. A path loses its middle rather than its end, because the directory that immediately contains the repo says more about where it is than the root of the tree does. The whole path, with the home directory still abbreviated, joins the names in the tooltip when it doesn't fit. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 117 ++++++++++++++---- pkg/i18n/english.go | 2 + .../tests/misc/recent_repos_column_widths.go | 45 +++++++ .../misc/recent_repos_with_long_names.go | 20 ++- pkg/integration/tests/test_list.go | 1 + 5 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 pkg/integration/tests/misc/recent_repos_column_widths.go diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 3821c5768..228affe31 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -103,22 +103,86 @@ func (self *ReposHelper) getCurrentBranch(path string) string { return self.c.Tr.BranchUnknown } -// The maximum width of the repo name and branch name columns of the recent -// repos menu. Without a limit, one long name pushes the path column off the -// right edge of the menu for every entry, because each column is padded to the -// width of its widest entry. -const recentReposColumnMaxWidth = 30 +// The most that the name and the branch column of the recent repos menu are +// allowed to take up. Each column is padded to the width of its widest entry, +// so without a limit one long entry pushes the columns after it off the right +// edge of the menu for every entry. +const ( + recentReposNameMaxWidth = 30 + recentReposBranchMaxWidth = 30 +) -func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *types.MenuItem { - repoName := filepath.Base(path) - displayedRepoName := utils.TruncateWithEllipsis(repoName, recentReposColumnMaxWidth) +// One entry of the recent repos menu. +type recentRepoEntry struct { + // What the entry stands for + path string + branchName string - // The icon is part of the column, so it counts towards the maximum width. + // The text of its three columns + nameColumn string + branchColumn string + dirColumn string +} + +func newRecentRepoEntry(path string, branchName string) recentRepoEntry { + // The icon is part of the column, so it counts towards the column's width. branchColumn := branchName if icons.IsIconEnabled() { branchColumn = icons.BRANCH_ICON + " " + branchName } - displayedBranchColumn := utils.TruncateWithEllipsis(branchColumn, recentReposColumnMaxWidth) + + return recentRepoEntry{ + path: path, + branchName: branchName, + nameColumn: filepath.Base(path), + branchColumn: branchColumn, + // The last segment of the path is already in the first column, so the + // directory that contains the repo is enough to tell repos with the + // same name apart. + dirColumn: utils.ContractTilde(filepath.Dir(path)), + } +} + +// How wide the columns of the recent repos menu are allowed to get. +type recentRepoColumnWidths struct { + name int + branch int + dir int +} + +// Gives the name and the branch column as much as their entries need, up to +// their respective maximum, and the rest of the row to the directory column. +// Measuring the entries first matters because most users don't have names that +// long; truncating the directories as if they did would cut them short for no +// reason. +func (self *ReposHelper) fitRecentRepoColumns(entries []recentRepoEntry) recentRepoColumnWidths { + // The menu appends a Cancel entry, whose label sits in the first column. + nameWidth := utils.StringWidth(self.c.Tr.Cancel) + branchWidth := 0 + for _, entry := range entries { + nameWidth = max(nameWidth, utils.StringWidth(entry.nameColumn)) + branchWidth = max(branchWidth, utils.StringWidth(entry.branchColumn)) + } + + nameWidth = min(nameWidth, recentReposNameMaxWidth) + branchWidth = min(branchWidth, recentReposBranchMaxWidth) + + return recentRepoColumnWidths{ + name: nameWidth, + branch: branchWidth, + // The menu's frame takes up two columns, and two more separate the + // three columns from each other. What's left is the room a whole row + // has in a menu that is as wide as it gets. + dir: menuMaxWidth - 2 - 2 - nameWidth - branchWidth, + } +} + +func (self *ReposHelper) recentRepoMenuItem(entry recentRepoEntry, widths recentRepoColumnWidths) *types.MenuItem { + displayedName := utils.TruncateWithEllipsis(entry.nameColumn, widths.name) + displayedBranch := utils.TruncateWithEllipsis(entry.branchColumn, widths.branch) + // The beginning and the end of a directory are both worth seeing, so it + // loses its middle rather than its end when it doesn't fit. + displayedDir := utils.TruncateWithEllipsisInMiddle(entry.dirColumn, widths.dir) // Spell out whatever the columns show in truncated form type tooltipField struct { @@ -130,11 +194,14 @@ func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *typ fields = append(fields, tooltipField{label: label, value: value}) } - if displayedRepoName != repoName { - addTooltipField(self.c.Tr.RecentReposRepoLabel, repoName) + if displayedName != entry.nameColumn { + addTooltipField(self.c.Tr.RecentReposRepoLabel, entry.nameColumn) } - if displayedBranchColumn != branchColumn { - addTooltipField(self.c.Tr.RecentReposBranchLabel, branchName) + if displayedBranch != entry.branchColumn { + addTooltipField(self.c.Tr.RecentReposBranchLabel, entry.branchName) + } + if displayedDir != entry.dirColumn { + addTooltipField(self.c.Tr.RecentReposPathLabel, entry.dirColumn) } // Line the values up behind the widest of the labels that are there @@ -147,16 +214,13 @@ func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *typ return &types.MenuItem{ LabelColumns: []string{ - displayedRepoName, - style.FgCyan.Sprint(displayedBranchColumn), - // The last segment of the path is already in the first column, so - // showing the directory that contains the repo is enough to tell - // repos with the same name apart. - style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))), + displayedName, + style.FgCyan.Sprint(displayedBranch), + style.FgMagenta.Sprint(displayedDir), }, // Filtering matches the full text, including the parts that the columns // above truncate or leave out. - FilterColumns: []string{repoName, branchName, path}, + 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 @@ -168,7 +232,7 @@ func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *typ // 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(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) + return self.switchTo(entry.path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) }, } } @@ -195,8 +259,13 @@ func (self *ReposHelper) CreateRecentReposMenu() error { wg.Wait() - menuItems := lo.Map(recentRepoPaths, func(path string, i int) *types.MenuItem { - return self.recentRepoMenuItem(path, currentBranches[i]) + entries := lo.Map(recentRepoPaths, func(path string, i int) recentRepoEntry { + return newRecentRepoEntry(path, currentBranches[i]) + }) + + columnWidths := self.fitRecentRepoColumns(entries) + menuItems := lo.Map(entries, func(entry recentRepoEntry, _ int) *types.MenuItem { + return self.recentRepoMenuItem(entry, columnWidths) }) return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 918247902..0f33d9bc0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -334,6 +334,7 @@ type TranslationSet struct { RecentRepos string RecentReposRepoLabel string RecentReposBranchLabel string + RecentReposPathLabel string MergeOptionsTitle string RebaseOptionsTitle string CherryPickOptionsTitle string @@ -1494,6 +1495,7 @@ func EnglishTranslationSet() *TranslationSet { RecentRepos: "Recent repositories", RecentReposRepoLabel: "Repo:", RecentReposBranchLabel: "Branch:", + RecentReposPathLabel: "Path:", MergeOptionsTitle: "Merge options", RebaseOptionsTitle: "Rebase options", CherryPickOptionsTitle: "Cherry-pick options", diff --git a/pkg/integration/tests/misc/recent_repos_column_widths.go b/pkg/integration/tests/misc/recent_repos_column_widths.go new file mode 100644 index 000000000..587e5d9b6 --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_column_widths.go @@ -0,0 +1,45 @@ +package misc + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RecentReposColumnWidths = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The directory column of the recent repositories menu gets the room that the short columns before it don't need", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "SHOW_RECENT_REPOS": "true", + }, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // the first entry is the repo we're in, so it isn't offered + current, _ := filepath.Abs(".") + target, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{current, target} + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.CloneNonBare("other") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The name and the branch are short, so the directory is shown in full + // even though it is longer than the third of the row it would get if + // they each took their maximum width + t.ExpectPopup().Menu(). + Title(Equals("Recent repositories")). + Lines( + Contains("other master ~/_results/misc/recent_repos_column_widths/actual").IsSelected(), + Contains("Cancel"), + ). + Tap(func() { + // Nothing is truncated, so there is nothing to spell out + t.Views().Tooltip().IsInvisible() + }). + Confirm() + + t.Views().Status().Content(Contains("other → master")) + }, +}) diff --git a/pkg/integration/tests/misc/recent_repos_with_long_names.go b/pkg/integration/tests/misc/recent_repos_with_long_names.go index 7396faff3..7bb18593c 100644 --- a/pkg/integration/tests/misc/recent_repos_with_long_names.go +++ b/pkg/integration/tests/misc/recent_repos_with_long_names.go @@ -18,10 +18,14 @@ var RecentReposWithLongNames = NewIntegrationTest(NewIntegrationTestArgs{ // the first entry is the repo we're in, so it isn't offered current, _ := filepath.Abs(".") target, _ := filepath.Abs("../repo-with-a-name-that-is-far-too-long") - cfg.GetAppState().RecentRepos = []string{current, target} + other, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{current, target, other} }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") + // this one is cloned while master is checked out, so only its path is + // long enough to be truncated + shell.CloneNonBare("other") shell.NewBranch("branch-with-a-name-that-is-too-long") shell.CloneNonBare("repo-with-a-name-that-is-far-too-long") }, @@ -29,10 +33,20 @@ var RecentReposWithLongNames = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().Menu(). Title(Equals("Recent repositories")). Lines( - Contains("repo-with-a-name-that-is-far-… branch-with-a-name-that-is-to…").IsSelected(), + // The repos live in the test's own directory, so their paths are + // long enough to lose their middle + Contains("repo-with-a-name-that-is-far-… branch-with-a-name-that-is-to… ~/_results/mi…names/actual").IsSelected(), + Contains("other master ~/_results/mi…names/actual"), Contains("Cancel"), ). - Tooltip(Equals("Repo: repo-with-a-name-that-is-far-too-long\nBranch: branch-with-a-name-that-is-too-long")). + Tooltip(Contains("Repo: repo-with-a-name-that-is-far-too-long\n" + + "Branch: branch-with-a-name-that-is-too-long\n" + + "Path: ~/_results/misc/recent_repos_with_long_names/actual")). + // The values are lined up behind the labels that are there, so a + // tooltip that only spells out the path doesn't indent it as if a + // "Branch:" label were in front of it + Select(Contains("other")). + Tooltip(Equals("Path: ~/_results/misc/recent_repos_with_long_names/actual")). // Filtering matches the full names, including the part that the // menu truncates Filter("too-long"). diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 6dcb226f1..c2f536bfa 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -365,6 +365,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.FilterRecentRepos, misc.InitialOpen, + misc.RecentReposColumnWidths, misc.RecentReposOnLaunch, misc.RecentReposWithLongNames, misc.StartInGitDir, From 8d0f81cc1561b743e6455c8d5876bcdb03420af0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:13:39 +0200 Subject: [PATCH 09/16] Make the worktrees panel's detached-head text translatable The recent repos menu is about to show the same text for a repo that has no branch checked out. Hardcoding the English a second time would leave translators with one of the two copies, so give the text a translation key and take it from there in both places. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/worktrees.go | 3 ++- pkg/gui/presentation/worktrees_test.go | 1 + pkg/i18n/english.go | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/gui/presentation/worktrees.go b/pkg/gui/presentation/worktrees.go index b2d99cb95..d0a506690 100644 --- a/pkg/gui/presentation/worktrees.go +++ b/pkg/gui/presentation/worktrees.go @@ -49,7 +49,8 @@ func GetWorktreeDisplayString(tr *i18n.TranslationSet, worktree *models.Worktree if worktree.Branch != "" { branch = style.FgCyan.Sprint(worktree.Branch) } else if worktree.Head != "" { - branch = style.FgYellow.Sprint("HEAD detached at " + utils.ShortHash(worktree.Head)) + branch = style.FgYellow.Sprint(utils.ResolvePlaceholderString( + tr.HeadDetachedAt, map[string]string{"hash": utils.ShortHash(worktree.Head)})) } res = append(res, branch+mainWorktreeLabel(tr, worktree)) return res diff --git a/pkg/gui/presentation/worktrees_test.go b/pkg/gui/presentation/worktrees_test.go index 73abe100d..a7b1ab4a5 100644 --- a/pkg/gui/presentation/worktrees_test.go +++ b/pkg/gui/presentation/worktrees_test.go @@ -14,6 +14,7 @@ func Test_GetWorktreeDisplayString(t *testing.T) { tr := &i18n.TranslationSet{ MainWorktree: "(main worktree)", MissingWorktree: "(missing)", + HeadDetachedAt: "HEAD detached at {{.hash}}", } scenarios := []struct { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0f33d9bc0..7a39cd716 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -718,6 +718,7 @@ type TranslationSet struct { BranchNotFoundTitle string BranchNotFoundPrompt string BranchUnknown string + HeadDetachedAt string DiscardChangeTitle string DiscardChangePrompt string DiscardLinesFromCommitTitle string @@ -1884,6 +1885,7 @@ func EnglishTranslationSet() *TranslationSet { BranchNotFoundTitle: "Branch not found", BranchNotFoundPrompt: "Branch not found. Create a new branch named", BranchUnknown: "Branch unknown", + HeadDetachedAt: "HEAD detached at {{.hash}}", DiscardChangeTitle: "Discard change", DiscardChangePrompt: "Are you sure you want to discard this change (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipDiscardChangeWarning' to true", DiscardLinesFromCommitTitle: "Discard lines from commit", From a3e940e20eb3d2c1ce59bd2d2bfe3b18f5029c17 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:16:16 +0200 Subject: [PATCH 10/16] Pull the reading of a repo's HEAD out of getCurrentBranch The next commits change what the recent repos menu shows for a repo without a branch, and teach it about layouts whose HEAD it reads wrongly today. Give the reading a function of its own first, one that reports what it found rather than what to display. This keeps the later changes apart from each other, and it lets the tests call the reading directly instead of going through a ReposHelper. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 93 +++++++++++-------- .../controllers/helpers/repos_helper_test.go | 80 ++++++++++++++++ 2 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 pkg/gui/controllers/helpers/repos_helper_test.go diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 228affe31..1942bd735 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -61,46 +61,65 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } +// What a repo has checked out. Exactly one of the two fields is set. +type headInfo struct { + // The name of the checked-out branch. + branch string + // The commit HEAD is detached at. + hash string +} + +// gitDirOfRepo returns the directory that holds the git data of the repo at +// repoPath, and whether it could be found. An ordinary repo keeps that data in +// a .git directory; a worktree and a submodule have a .git file naming the +// directory instead. +func gitDirOfRepo(repoPath string) (string, bool) { + gitDirPath := filepath.Join(repoPath, ".git") + + stat, err := os.Stat(gitDirPath) + if err != nil { + return "", false + } + if stat.IsDir() { + return gitDirPath, true + } + + content, err := os.ReadFile(gitDirPath) + if err != nil { + return "", false + } + return strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") +} + +// readHeadInfo reads the HEAD file of the repo at repoPath to find out what it +// has checked out, and reports whether that worked. +func readHeadInfo(repoPath string) (headInfo, bool) { + gitDir, ok := gitDirOfRepo(repoPath) + if !ok { + return headInfo{}, false + } + + content, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) + if err != nil { + return headInfo{}, false + } + + head := strings.TrimSpace(string(content)) + if branch, ok := strings.CutPrefix(head, "ref: refs/heads/"); ok { + return headInfo{branch: branch}, true + } + return headInfo{hash: head}, true +} + func (self *ReposHelper) getCurrentBranch(path string) string { - readHeadFile := func(path string) (string, error) { - headFile, err := os.ReadFile(filepath.Join(path, "HEAD")) - if err == nil { - content := strings.TrimSpace(string(headFile)) - refsPrefix := "ref: refs/heads/" - var branchDisplay string - if bareName, ok := strings.CutPrefix(content, refsPrefix); ok { - // is a branch - branchDisplay = bareName - } else { - // detached HEAD state, displaying short hash - branchDisplay = utils.ShortHash(content) - } - return branchDisplay, nil - } - return "", err + head, ok := readHeadInfo(path) + if !ok { + return self.c.Tr.BranchUnknown } - - gitDirPath := filepath.Join(path, ".git") - - if gitDir, err := os.Stat(gitDirPath); err == nil { - if gitDir.IsDir() { - // ordinary repo - if branch, err := readHeadFile(gitDirPath); err == nil { - return branch - } - } else { - // worktree - if worktreeGitDir, err := os.ReadFile(gitDirPath); err == nil { - content := strings.TrimSpace(string(worktreeGitDir)) - worktreePath := strings.TrimPrefix(content, "gitdir: ") - if branch, err := readHeadFile(worktreePath); err == nil { - return branch - } - } - } + if head.branch != "" { + return head.branch } - - return self.c.Tr.BranchUnknown + return utils.ShortHash(head.hash) } // The most that the name and the branch column of the recent repos menu are diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go new file mode 100644 index 000000000..be36a7f58 --- /dev/null +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -0,0 +1,80 @@ +package helpers + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadHeadInfo(t *testing.T) { + scenarios := []struct { + name string + // The files to lay out below a temporary root directory, by their path + // relative to it. "$root" in a file's content is replaced with the + // root's path, so that a scenario can write an absolute path. + files map[string]string + // The repo to read, relative to the root. The directory is created + // whether or not the scenario puts any files in it. + repoPath string + expected headInfo + expectedOk bool + }{ + { + name: "ordinary repo on a branch", + files: map[string]string{"repo/.git/HEAD": "ref: refs/heads/mybranch\n"}, + repoPath: "repo", + expected: headInfo{branch: "mybranch"}, + expectedOk: true, + }, + { + name: "ordinary repo at a detached head", + files: map[string]string{"repo/.git/HEAD": "d85cc9d2f5d0dc0b8f0e4d8e5b2ba0d1e7c8a3f6\n"}, + repoPath: "repo", + expected: headInfo{hash: "d85cc9d2f5d0dc0b8f0e4d8e5b2ba0d1e7c8a3f6"}, + expectedOk: true, + }, + { + name: "worktree whose .git file names the git dir absolutely", + files: map[string]string{ + "repo/.git/worktrees/wt/HEAD": "ref: refs/heads/mybranch\n", + "wt/.git": "gitdir: $root/repo/.git/worktrees/wt\n", + }, + repoPath: "wt", + expected: headInfo{branch: "mybranch"}, + expectedOk: true, + }, + { + name: "directory without a .git entry", + repoPath: "notarepo", + expectedOk: false, + }, + { + name: ".git file that doesn't name a git dir", + files: map[string]string{"repo/.git": "not what git writes\n"}, + repoPath: "repo", + expectedOk: false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + root := t.TempDir() + for path, content := range s.files { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + assert.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700)) + content = strings.ReplaceAll(content, "$root", root) + assert.NoError(t, os.WriteFile(fullPath, []byte(content), 0o600)) + } + repoPath := filepath.Join(root, filepath.FromSlash(s.repoPath)) + assert.NoError(t, os.MkdirAll(repoPath, 0o700)) + + head, ok := readHeadInfo(repoPath) + + assert.Equal(t, s.expectedOk, ok) + assert.Equal(t, s.expected, head) + }) + } +} From 9e542ff6e067fc50e0ce999b18c62d2ce271e95e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:18:21 +0200 Subject: [PATCH 11/16] Show "HEAD detached at " in the recent repos menu A repo with no branch checked out puts a bare short hash in the branch column, where it reads as a branch name. Spell it out the way the worktrees panel already does. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 3 +- .../tests/misc/recent_repos_branch_column.go | 40 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/misc/recent_repos_branch_column.go diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 1942bd735..d22230fd9 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -119,7 +119,8 @@ func (self *ReposHelper) getCurrentBranch(path string) string { if head.branch != "" { return head.branch } - return utils.ShortHash(head.hash) + return utils.ResolvePlaceholderString(self.c.Tr.HeadDetachedAt, + map[string]string{"hash": utils.ShortHash(head.hash)}) } // The most that the name and the branch column of the recent repos menu are diff --git a/pkg/integration/tests/misc/recent_repos_branch_column.go b/pkg/integration/tests/misc/recent_repos_branch_column.go new file mode 100644 index 000000000..77562102b --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_branch_column.go @@ -0,0 +1,40 @@ +package misc + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RecentReposBranchColumn = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The branch column of the recent repositories menu shows what each repo has checked out", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "SHOW_RECENT_REPOS": "true", + }, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // the first entry is the repo we're in, so it isn't offered + current, _ := filepath.Abs(".") + onBranch, _ := filepath.Abs("../on-branch") + detached, _ := filepath.Abs("../detached") + cfg.GetAppState().RecentRepos = []string{current, onBranch, detached} + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.CloneNonBare("on-branch") + shell.CloneNonBare("detached") + shell.RunCommand([]string{"git", "-C", "../detached", "checkout", "--detach"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.ExpectPopup().Menu(). + Title(Equals("Recent repositories")). + Lines( + Contains("on-branch").Contains("master").IsSelected(), + Contains("detached").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`), + Contains("Cancel"), + ). + Cancel() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c2f536bfa..89ae48de0 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -365,6 +365,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.FilterRecentRepos, misc.InitialOpen, + misc.RecentReposBranchColumn, misc.RecentReposColumnWidths, misc.RecentReposOnLaunch, misc.RecentReposWithLongNames, From 00c0c4c061a10e64ba20340af0ecdea1e9398453 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:20:22 +0200 Subject: [PATCH 12/16] Demonstrate that the recent repos menu can't read a submodule's branch The menu shows "Branch unknown" for every submodule, whatever it has checked out. A submodule has no .git directory; its .git is a file naming the directory, and for a submodule git writes that name relative to the submodule. We hand it to os.ReadFile unchanged, so it resolves against lazygit's own working directory and the read fails. A worktree created with --relative-paths (or with worktree.useRelativePaths set) gets a relative name too, and fails the same way. Co-Authored-By: Claude Opus 5 (1M context) --- .../controllers/helpers/repos_helper_test.go | 28 +++++++++++++++++++ .../tests/misc/recent_repos_branch_column.go | 11 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go index be36a7f58..55e0508bd 100644 --- a/pkg/gui/controllers/helpers/repos_helper_test.go +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -46,6 +46,34 @@ func TestReadHeadInfo(t *testing.T) { expected: headInfo{branch: "mybranch"}, expectedOk: true, }, + { + name: "worktree whose .git file names the git dir relatively", + files: map[string]string{ + "repo/.git/worktrees/wt/HEAD": "ref: refs/heads/mybranch\n", + "wt/.git": "gitdir: ../repo/.git/worktrees/wt\n", + }, + /* EXPECTED: + repoPath: "wt", + expected: headInfo{branch: "mybranch"}, + expectedOk: true, + ACTUAL: */ + repoPath: "wt", + expectedOk: false, + }, + { + name: "submodule, whose .git file always names the git dir relatively", + files: map[string]string{ + "repo/.git/modules/sub/HEAD": "ref: refs/heads/mybranch\n", + "repo/sub/.git": "gitdir: ../.git/modules/sub\n", + }, + /* EXPECTED: + repoPath: "repo/sub", + expected: headInfo{branch: "mybranch"}, + expectedOk: true, + ACTUAL: */ + repoPath: "repo/sub", + expectedOk: false, + }, { name: "directory without a .git entry", repoPath: "notarepo", diff --git a/pkg/integration/tests/misc/recent_repos_branch_column.go b/pkg/integration/tests/misc/recent_repos_branch_column.go index 77562102b..9c0257edb 100644 --- a/pkg/integration/tests/misc/recent_repos_branch_column.go +++ b/pkg/integration/tests/misc/recent_repos_branch_column.go @@ -19,13 +19,18 @@ var RecentReposBranchColumn = NewIntegrationTest(NewIntegrationTestArgs{ current, _ := filepath.Abs(".") onBranch, _ := filepath.Abs("../on-branch") detached, _ := filepath.Abs("../detached") - cfg.GetAppState().RecentRepos = []string{current, onBranch, detached} + submodule, _ := filepath.Abs("sub") + cfg.GetAppState().RecentRepos = []string{current, onBranch, detached, submodule} }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneNonBare("on-branch") shell.CloneNonBare("detached") shell.RunCommand([]string{"git", "-C", "../detached", "checkout", "--detach"}) + shell.CloneIntoSubmodule("submodule", "sub") + shell.GitAddAll() + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "-C", "sub", "checkout", "--detach"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.ExpectPopup().Menu(). @@ -33,6 +38,10 @@ var RecentReposBranchColumn = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("on-branch").Contains("master").IsSelected(), Contains("detached").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`), + /* EXPECTED: + Contains("sub").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`), + ACTUAL: */ + Contains("sub").Contains("Branch unknown"), Contains("Cancel"), ). Cancel() From 93713b54018cfb11ffc748832ddef708b89b9d9a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:27:46 +0200 Subject: [PATCH 13/16] Resolve a relative gitdir against the repo it belongs to Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 11 ++++++++++- pkg/gui/controllers/helpers/repos_helper_test.go | 8 -------- .../tests/misc/recent_repos_branch_column.go | 3 --- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index d22230fd9..d12238f33 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -88,7 +88,16 @@ func gitDirOfRepo(repoPath string) (string, bool) { if err != nil { return "", false } - return strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") + gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") + if !ok { + return "", false + } + // A relative name is relative to the repo. Git writes one for a submodule, + // and for a worktree created with --relative-paths. + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + return gitDir, true } // readHeadInfo reads the HEAD file of the repo at repoPath to find out what it diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go index 55e0508bd..5b6f5bb4b 100644 --- a/pkg/gui/controllers/helpers/repos_helper_test.go +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -52,13 +52,9 @@ func TestReadHeadInfo(t *testing.T) { "repo/.git/worktrees/wt/HEAD": "ref: refs/heads/mybranch\n", "wt/.git": "gitdir: ../repo/.git/worktrees/wt\n", }, - /* EXPECTED: repoPath: "wt", expected: headInfo{branch: "mybranch"}, expectedOk: true, - ACTUAL: */ - repoPath: "wt", - expectedOk: false, }, { name: "submodule, whose .git file always names the git dir relatively", @@ -66,13 +62,9 @@ func TestReadHeadInfo(t *testing.T) { "repo/.git/modules/sub/HEAD": "ref: refs/heads/mybranch\n", "repo/sub/.git": "gitdir: ../.git/modules/sub\n", }, - /* EXPECTED: repoPath: "repo/sub", expected: headInfo{branch: "mybranch"}, expectedOk: true, - ACTUAL: */ - repoPath: "repo/sub", - expectedOk: false, }, { name: "directory without a .git entry", diff --git a/pkg/integration/tests/misc/recent_repos_branch_column.go b/pkg/integration/tests/misc/recent_repos_branch_column.go index 9c0257edb..3460d9bea 100644 --- a/pkg/integration/tests/misc/recent_repos_branch_column.go +++ b/pkg/integration/tests/misc/recent_repos_branch_column.go @@ -38,10 +38,7 @@ var RecentReposBranchColumn = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("on-branch").Contains("master").IsSelected(), Contains("detached").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`), - /* EXPECTED: Contains("sub").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`), - ACTUAL: */ - Contains("sub").Contains("Branch unknown"), Contains("Cancel"), ). Cancel() From 7b5c6f4e124ea1082eed0a14613463343268bdc9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:29:32 +0200 Subject: [PATCH 14/16] Demonstrate that the recent repos menu shows ".invalid" for a reftable repo A repo that keeps its refs in a reftable shows ".invalid" in the branch column. Git stores the real HEAD in a binary table there and leaves "ref: refs/heads/.invalid" in the HEAD file, so that anything still reading the HEAD file fails loudly instead of getting a stale answer. We read that file and take the placeholder for a branch name. Co-Authored-By: Claude Opus 5 (1M context) --- .../controllers/helpers/repos_helper_test.go | 13 ++++++ .../tests/misc/recent_repos_reftable_repo.go | 40 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 54 insertions(+) create mode 100644 pkg/integration/tests/misc/recent_repos_reftable_repo.go diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go index 5b6f5bb4b..1bbcd9b6c 100644 --- a/pkg/gui/controllers/helpers/repos_helper_test.go +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -66,6 +66,19 @@ func TestReadHeadInfo(t *testing.T) { expected: headInfo{branch: "mybranch"}, expectedOk: true, }, + { + name: "repo that keeps its refs in a reftable, so HEAD holds a placeholder", + files: map[string]string{ + "repo/.git/HEAD": "ref: refs/heads/.invalid\n", + }, + /* EXPECTED: + repoPath: "repo", + expectedOk: false, + ACTUAL: */ + repoPath: "repo", + expected: headInfo{branch: ".invalid"}, + expectedOk: true, + }, { name: "directory without a .git entry", repoPath: "notarepo", diff --git a/pkg/integration/tests/misc/recent_repos_reftable_repo.go b/pkg/integration/tests/misc/recent_repos_reftable_repo.go new file mode 100644 index 000000000..616681063 --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_reftable_repo.go @@ -0,0 +1,40 @@ +package misc + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RecentReposReftableRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The recent repositories menu shows the branch of a repo that keeps its refs in a reftable", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "SHOW_RECENT_REPOS": "true", + }, + Skip: false, + GitVersion: AtLeast("2.45.0"), + SetupConfig: func(cfg *config.AppConfig) { + // the first entry is the repo we're in, so it isn't offered + current, _ := filepath.Abs(".") + reftable, _ := filepath.Abs("../reftable") + cfg.GetAppState().RecentRepos = []string{current, reftable} + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.RunCommand([]string{"git", "clone", "--ref-format=reftable", ".", "../reftable"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.ExpectPopup().Menu(). + Title(Equals("Recent repositories")). + Lines( + /* EXPECTED: + Contains("reftable").Contains("master").IsSelected(), + ACTUAL: */ + Contains("reftable").Contains(".invalid").IsSelected(), + Contains("Cancel"), + ). + Cancel() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 89ae48de0..ef73e79b1 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -368,6 +368,7 @@ var tests = []*components.IntegrationTest{ misc.RecentReposBranchColumn, misc.RecentReposColumnWidths, misc.RecentReposOnLaunch, + misc.RecentReposReftableRepo, misc.RecentReposWithLongNames, misc.StartInGitDir, patch_building.Apply, From 339b4c45554a496528c492efb92267fddbecabb6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:31:20 +0200 Subject: [PATCH 15/16] Export ForOtherRepo The recent repos helper is about to run a git command against each repo in the menu, and it needs the same treatment of GIT_DIR and GIT_WORK_TREE that everything else pointed at another repo gets. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/git_command_builder.go | 4 ++-- pkg/commands/git_commands/repo_paths.go | 4 ++-- pkg/commands/git_commands/submodule.go | 10 +++++----- pkg/commands/git_commands/worktree.go | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index f1a7c87b4..43bcd7206 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -19,12 +19,12 @@ import ( // that opts back in is the foreground files refresh; see FileLoader.gitStatus. const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS" -// forOtherRepo prepares a command that operates on a repo other than the one +// ForOtherRepo prepares a command that operates on a repo other than the one // we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE // say where our repo is, and every command we run inherits them, so a command // pointed at a different repo would be resolved against ours instead: `git -C // log` would silently log the superproject's commits. -func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj { +func ForOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj { return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar) } diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 0473f8f8e..088dd86b6 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -271,13 +271,13 @@ func callGitRevParseWithDir( return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...)) } -// Asks git about a repo that isn't the one we have open; see forOtherRepo. +// Asks git about a repo that isn't the one we have open; see ForOtherRepo. func callGitRevParseInOtherRepo( cmd oscommands.ICmdObjBuilder, dir string, gitRevArgs ...string, ) (string, error) { - return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...))) + return runGitRevParse(ForOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...))) } func newGitRevParseCmd( diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 7400f0514..0fa5de9df 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string Config("log.showsignature=false"). ToArgv() - summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() + summary, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() return strings.TrimSpace(summary), err } @@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string // caller then stages the submodule to record the resolution. func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error { cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv() - return forOtherRepo(self.cmd.New(cmdArgs)).Run() + return ForOtherRepo(self.cmd.New(cmdArgs)).Run() } // ConflictSideLog returns a oneline log, run inside the submodule, of the commits @@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi Arg("--oneline", "--color=always", otherSide+".."+side). ToArgv() - return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() + return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() } func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { @@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { Arg("--include-untracked"). ToArgv() - return forOtherRepo(self.cmd.New(cmdArgs)).Run() + return ForOtherRepo(self.cmd.New(cmdArgs)).Run() } func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { @@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error { // need not be. func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { if submodule.ParentModule != nil { - forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath())) + ForOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath())) } return cmdObj.Run() } diff --git a/pkg/commands/git_commands/worktree.go b/pkg/commands/git_commands/worktree.go index 64748b878..938287270 100644 --- a/pkg/commands/git_commands/worktree.go +++ b/pkg/commands/git_commands/worktree.go @@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error { func (self *WorktreeCommands) Detach(worktreePath string) error { cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv() - return forOtherRepo(self.cmd.New(cmdArgs)).Run() + return ForOtherRepo(self.cmd.New(cmdArgs)).Run() } func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) { From 3868a9407b11a394b240739ba909ea331b441d38 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:36:56 +0200 Subject: [PATCH 16/16] Ask git for the branch when the HEAD file doesn't hold it Reading HEAD is worth keeping for the repos it can answer for, because the menu opens on a keystroke and asking git costs a process per entry. So go to git only for the placeholder, and for anything else the read can't make sense of. That last part also gets the menu an answer for layouts we don't know about yet, where it used to give up and say "Branch unknown". Git needs two commands to cover every repo. `git symbolic-ref` names the branch even when it has no commit yet; `git rev-parse` resolves a detached HEAD, and fails on a branch without a commit. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 44 +++++++++++++++++++ .../controllers/helpers/repos_helper_test.go | 5 --- .../tests/misc/recent_repos_reftable_repo.go | 10 +++-- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index d12238f33..53018a2e1 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -10,6 +10,7 @@ import ( appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/direnv" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gui/context" @@ -100,6 +101,12 @@ func gitDirOfRepo(repoPath string) (string, bool) { return gitDir, true } +// The branch git names in the HEAD file of a repo that keeps its refs in a +// reftable. The refs live in a binary table there, and the name in HEAD +// resolves nowhere, so that a reader of the file gets an error instead of a +// stale answer. +const reftablePlaceholderBranch = ".invalid" + // readHeadInfo reads the HEAD file of the repo at repoPath to find out what it // has checked out, and reports whether that worked. func readHeadInfo(repoPath string) (headInfo, bool) { @@ -115,13 +122,50 @@ func readHeadInfo(repoPath string) (headInfo, bool) { head := strings.TrimSpace(string(content)) if branch, ok := strings.CutPrefix(head, "ref: refs/heads/"); ok { + if branch == reftablePlaceholderBranch { + return headInfo{}, false + } return headInfo{branch: branch}, true } return headInfo{hash: head}, true } +// askGitForHeadInfo asks git what the repo at repoPath has checked out. This +// costs a process, so only the repos that readHeadInfo can't answer for go +// through here. +func (self *ReposHelper) askGitForHeadInfo(repoPath string) (headInfo, bool) { + // symbolic-ref names the branch even when it has no commit yet, and + // rev-parse resolves HEAD when it is detached. Neither can do the other's + // job, so ask for the branch first and only then for the commit. + if branch, ok := self.askGit(repoPath, "symbolic-ref", "--short", "--quiet", "HEAD"); ok { + return headInfo{branch: branch}, true + } + if hash, ok := self.askGit(repoPath, "rev-parse", "HEAD"); ok { + return headInfo{hash: hash}, true + } + return headInfo{}, false +} + +// askGit runs a git command against the repo at repoPath and returns the one +// line it writes, or false if it fails or writes nothing. +func (self *ReposHelper) askGit(repoPath string, subcommand string, args ...string) (string, bool) { + cmdObj := self.c.OS().Cmd.New(git_commands.NewGitCmd(subcommand). + Dir(repoPath). + Arg(args...). + ToArgv()).DontLog() + stdout, _, err := git_commands.ForOtherRepo(cmdObj).RunWithOutputs() + if err != nil { + return "", false + } + output := strings.TrimSpace(stdout) + return output, output != "" +} + func (self *ReposHelper) getCurrentBranch(path string) string { head, ok := readHeadInfo(path) + if !ok { + head, ok = self.askGitForHeadInfo(path) + } if !ok { return self.c.Tr.BranchUnknown } diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go index 1bbcd9b6c..5e6b2e8c7 100644 --- a/pkg/gui/controllers/helpers/repos_helper_test.go +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -71,13 +71,8 @@ func TestReadHeadInfo(t *testing.T) { files: map[string]string{ "repo/.git/HEAD": "ref: refs/heads/.invalid\n", }, - /* EXPECTED: repoPath: "repo", expectedOk: false, - ACTUAL: */ - repoPath: "repo", - expected: headInfo{branch: ".invalid"}, - expectedOk: true, }, { name: "directory without a .git entry", diff --git a/pkg/integration/tests/misc/recent_repos_reftable_repo.go b/pkg/integration/tests/misc/recent_repos_reftable_repo.go index 616681063..797976bae 100644 --- a/pkg/integration/tests/misc/recent_repos_reftable_repo.go +++ b/pkg/integration/tests/misc/recent_repos_reftable_repo.go @@ -19,20 +19,22 @@ var RecentReposReftableRepo = NewIntegrationTest(NewIntegrationTestArgs{ // the first entry is the repo we're in, so it isn't offered current, _ := filepath.Abs(".") reftable, _ := filepath.Abs("../reftable") - cfg.GetAppState().RecentRepos = []string{current, reftable} + unborn, _ := filepath.Abs("../reftable-unborn") + cfg.GetAppState().RecentRepos = []string{current, reftable, unborn} }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.RunCommand([]string{"git", "clone", "--ref-format=reftable", ".", "../reftable"}) + // A branch without a commit is the one thing git can't answer for with + // rev-parse + shell.RunCommand([]string{"git", "init", "--ref-format=reftable", "../reftable-unborn"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.ExpectPopup().Menu(). Title(Equals("Recent repositories")). Lines( - /* EXPECTED: Contains("reftable").Contains("master").IsSelected(), - ACTUAL: */ - Contains("reftable").Contains(".invalid").IsSelected(), + Contains("reftable-unborn").Contains("master"), Contains("Cancel"), ). Cancel()