From 3868a9407b11a394b240739ba909ea331b441d38 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:36:56 +0200 Subject: [PATCH] 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()