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) + }) + } +}