From 0ce248d1bf25ef7d422fe51441b7cf63643cdea9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:49:32 +0200 Subject: [PATCH] Recognize a repo that has no work tree instead of bailing out git makes `rev-parse --show-toplevel` fatal when there's no work tree, so asking for it together with everything else meant we never got an answer at all for a bare repo: GetRepoPaths returned an error, nobody ever saw IsBareRepo() == true, and lazygit either died with a stack trace or decided we weren't in a repository. That's what you got for opening it in a directory holding a bare repo and a .git file pointing at it, which is a normal way to keep a repo and its worktrees together. Ask again without --show-toplevel when the first query fails: the other queries work fine without a work tree, so if they now succeed we know we're in a bare repo, and the existing prompt offering to open a recent repo does its job. If they fail too we're not in a repo at all, and the first error already says so. --is-bare-repository is gone from the query: a work tree implies core.bare is false, so it could only ever come back false there, and what matters to us is whether there is a work tree to show, which is what we now go by. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/app/errors.go | 2 +- pkg/commands/git.go | 7 ++ pkg/commands/git_commands/repo_paths.go | 55 ++++++++++++-- pkg/commands/git_commands/repo_paths_test.go | 77 +++++++++----------- pkg/i18n/english.go | 2 + 5 files changed, 91 insertions(+), 52 deletions(-) diff --git a/pkg/app/errors.go b/pkg/app/errors.go index 506fec276..ee24cff49 100644 --- a/pkg/app/errors.go +++ b/pkg/app/errors.go @@ -16,7 +16,7 @@ type errorMapping struct { func knownError(tr *i18n.TranslationSet, err error) (string, bool) { errorMessage := err.Error() - knownErrorMessages := []string{minGitVersionErrorMessage(tr)} + knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported} if lo.Contains(knownErrorMessages, errorMessage) { return errorMessage, true diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 6a08c1d2b..a5fc0e608 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -67,6 +67,13 @@ func NewGitCommand( return nil, errors.Errorf("Error getting repo paths: %v", err) } + // A bare repo has no worktree for us to work in. Callers that can offer the + // user something better (app.setupRepo) check for this first; getting here + // means nobody could, e.g. because --git-dir was pointed at a bare repo. + if repoPaths.IsBareRepo() { + return nil, errors.New(cmn.Tr.BareRepoNotSupported) + } + err = os.Chdir(repoPaths.WorktreePath()) if err != nil { return nil, utils.WrapError(err) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 6783c0971..89a86640b 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -22,7 +22,8 @@ type RepoPaths struct { } // Path to the current worktree. If we're in the main worktree, this will -// be the same as RepoPath() +// be the same as RepoPath(). It is empty for a bare repo, which has no +// worktree at all. func (self *RepoPaths) WorktreePath() string { return self.worktreePath } @@ -53,6 +54,9 @@ func (self *RepoPaths) RepoName() string { return self.repoName } +// Whether the repo has no worktree, so that there is nothing for lazygit to +// show. Note that this isn't quite git's core.bare: a repo that calls itself +// non-bare but doesn't have a worktree either counts as bare for us. func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } @@ -84,16 +88,18 @@ func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, ) (*RepoPaths, error) { - gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree") + gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree") if err != nil { - return nil, err + // --show-toplevel is the only one of these that needs a work tree, and + // git makes it fatal when there isn't one. So this may just mean we're in + // a repo that has no work tree. + return getBareRepoPathsForDir(dir, cmd, err) } gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n") worktreePath := gitDirResults[0] worktreeGitDirPath := gitDirResults[1] repoGitDirPath := gitDirResults[2] - isBareRepo := gitDirResults[3] == "true" // A worktree that has the repo's common git dir to itself is the repo's main // worktree, so it is the repoPath. That holds for a submodule as well: its @@ -102,9 +108,9 @@ func GetRepoPathsForDir( isMainWorktree := worktreeGitDirPath == repoGitDirPath // If we're in a submodule, --show-superproject-working-tree will return a - // value, meaning gitDirResults will be length 5. That only tells us anything + // value, meaning gitDirResults will be length 4. That only tells us anything // new for a linked worktree of a submodule, which isMainWorktree misses. - isSubmodule := len(gitDirResults) == 5 + isSubmodule := len(gitDirResults) == 4 // Otherwise we're in a linked worktree, and the repoPath is the repo's main // worktree. git won't tell us where that is: `git worktree list` reports it @@ -131,7 +137,42 @@ func GetRepoPathsForDir( repoPath: repoPath, repoGitDirPath: repoGitDirPath, repoName: repoName, - isBareRepo: isBareRepo, + isBareRepo: false, + }, nil +} + +// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the +// work tree. Everything but --show-toplevel works fine without one, so if the +// remaining queries succeed we are in a bare repo, and we return what we know +// about it with an empty worktreePath. If they fail too we simply aren't in a +// repo, and the caller's original error says so better than ours would. +func getBareRepoPathsForDir( + dir string, + cmd oscommands.ICmdObjBuilder, + errWithWorktree error, +) (*RepoPaths, error) { + output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir") + if err != nil { + return nil, errWithWorktree + } + + results := strings.Split(utils.NormalizeLinefeeds(output), "\n") + repoGitDirPath := results[1] + // A bare repo has no worktree, and so no repo path in the sense the caller + // with a worktree means. It doesn't matter much what we say here, because + // nobody reads it: whoever is handed a bare repo either offers to open a + // recent one instead (app.setupRepo) or is turned away by NewGitCommand. The + // directory holding the git dir is the nearest thing there is to a repo + // path. + repoPath := filepath.Dir(repoGitDirPath) + + return &RepoPaths{ + worktreePath: "", + worktreeGitDirPath: results[0], + repoPath: repoPath, + repoGitDirPath: repoGitDirPath, + repoName: filepath.Base(repoPath), + isBareRepo: true, }, nil } diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 25fd1b2e0..fed4905a8 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -76,49 +72,45 @@ func TestGetRepoPaths(t *testing.T) { Err: nil, }, { + // git refuses to answer --show-toplevel when there's no work tree, so + // we have to ask a second time without it. Name: "bare repo", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { - // setup for main worktree + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: this operation must be run in a work tree")) + mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ - // --show-toplevel - `C:\path\to\repo`, // --git-dir - `C:\path\to\bare_repo\bare.git`, + `C:\path\to\project\bare.git`, // --git-common-dir - `C:\path\to\bare_repo\bare.git`, - // --is-bare-repository - `true`, - // --show-superproject-working-tree + `C:\path\to\project\bare.git`, }, []string{ - // --show-toplevel - "/path/to/repo", // --git-dir - "/path/to/bare_repo/bare.git", + "/path/to/project/bare.git", // --git-common-dir - "/path/to/bare_repo/bare.git", - // --is-bare-repository - "true", - // --show-superproject-working-tree + "/path/to/project/bare.git", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), strings.Join(mockOutput, "\n"), nil) }, - Path: "/path/to/repo", + Path: "/path/to/project", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ - worktreePath: `C:\path\to\repo`, - worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoPath: `C:\path\to\repo`, - repoGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoName: `repo`, + worktreePath: "", + worktreeGitDirPath: `C:\path\to\project\bare.git`, + repoPath: `C:\path\to\project`, + repoGitDirPath: `C:\path\to\project\bare.git`, + repoName: `project`, isBareRepo: true, }, &RepoPaths{ - worktreePath: "/path/to/repo", - worktreeGitDirPath: "/path/to/bare_repo/bare.git", - repoPath: "/path/to/repo", - repoGitDirPath: "/path/to/bare_repo/bare.git", - repoName: "repo", + worktreePath: "", + worktreeGitDirPath: "/path/to/project/bare.git", + repoPath: "/path/to/project", + repoGitDirPath: "/path/to/project/bare.git", + repoName: "project", isBareRepo: true, }), Err: nil, @@ -136,8 +128,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -146,12 +136,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -183,8 +171,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git\modules\submodule1`, // --git-common-dir `C:\path\to\repo\.git\modules\submodule1`, - // --is-bare-repository - `false`, // --show-superproject-working-tree `C:\path\to\repo`, }, []string{ @@ -194,13 +180,11 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git/modules/submodule1", // --git-common-dir "/path/to/repo/.git/modules/submodule1", - // --is-bare-repository - "false", // --show-superproject-working-tree "/path/to/repo", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -226,7 +210,12 @@ func TestGetRepoPaths(t *testing.T) { Name: "git rev-parse returns an error", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) + // we're not in a repo at all, so asking about a bare one fails too + runner.ExpectGitArgs( + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), "", errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) }, @@ -234,7 +223,7 @@ func TestGetRepoPaths(t *testing.T) { Expected: nil, Err: func(getRevParseArgs argFn) error { args := strings.Join(getRevParseArgs(), " ") - return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) + return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) }, }, } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index da157ff78..f71e61adb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -461,6 +461,7 @@ type TranslationSet struct { DisabledForGPG string CreateRepo string BareRepo string + BareRepoNotSupported string InitialBranch string NoRecentRepositories string IncorrectNotARepository string @@ -1620,6 +1621,7 @@ func EnglishTranslationSet() *TranslationSet { DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", + BareRepoNotSupported: "Lazygit does not support bare repos.", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.",