From a3e940e20eb3d2c1ce59bd2d2bfe3b18f5029c17 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 14 Sep 2026 10:16:16 +0200 Subject: [PATCH] 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) + }) + } +}