jesseduffield.lazygit/pkg/commands/git_commands/repo_paths.go
Stefan Haller 80ad2db71a Recognize worktrees among the files from the worktrees model
Finding out which of the files are worktrees of ours had its own answer
to where this repo's worktrees are, walking the directory that git keeps
them in. The worktrees panel asks git itself, and that is the better
answer: it is the one git gives for the same question elsewhere in the
app, and it doesn't need to know where git records what.

The model that panel fills is all the files need, so mark them from it.
That takes the work out of the file loader, whose other two callers were
paying for it without wanting it, and it costs no git call at all: both
models are written on the UI thread, so whichever of the two refreshes
lands second marks the files against the other's fresh data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:26:58 +02:00

303 lines
11 KiB
Go

package git_commands
import (
"os"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type RepoPaths struct {
worktreePath string
worktreeGitDirPath string
repoPath string
repoGitDirPath string
repoName string
isBareRepo bool
gitLocationEnvVars []string
}
// Path to the current worktree. If we're in the main worktree, this will
// 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
}
// Path of the worktree's git dir.
// If we're in the main worktree, this will be the .git dir under the RepoPath().
// If we're in a linked worktree, it will be the directory pointed at by the worktree's .git file
func (self *RepoPaths) WorktreeGitDirPath() string {
return self.worktreeGitDirPath
}
// Path of the repo. If we're in a the main worktree, this will be the same as WorktreePath()
// If we're in a bare repo, it will be the parent folder of the bare repo
func (self *RepoPaths) RepoPath() string {
return self.repoPath
}
// path of the git-dir for the repo.
// If this is a bare repo, it will be the location of the bare repo
// If this is a non-bare repo, it will be the location of the .git dir in
// the main worktree.
func (self *RepoPaths) RepoGitDirPath() string {
return self.repoGitDirPath
}
// Name of the repo. Basename of the folder containing the repo.
func (self *RepoPaths) RepoName() string {
return self.repoName
}
// Whether we found 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 whose worktree we couldn't find counts as bare for us too. Concretely,
// this is true when we're in
//
// - a genuinely bare repo;
// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is
// recorded but not somewhere we look;
// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such
// as a vcsh-style dotfiles repo that hasn't been given core.worktree.
//
// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir
// notices the worktree holding it and hands back that repo instead.
func (self *RepoPaths) IsBareRepo() bool {
return self.isBareRepo
}
// The environment that tells git where this repo is, as "NAME=value" entries.
// It is empty for the vast majority of repos, which git finds for itself by
// looking for a .git in the directory a command runs in. It is only non-empty
// when that doesn't work — when the git dir lives somewhere else entirely,
// because of core.worktree or --work-tree — and then every command addressing
// the repo has to carry it.
func (self *RepoPaths) GitLocationEnvVars() []string {
return self.gitLocationEnvVars
}
// Returns the repo paths for a typical repo
func MockRepoPaths(currentPath string) *RepoPaths {
return &RepoPaths{
worktreePath: currentPath,
worktreeGitDirPath: filepath.Join(currentPath, ".git"),
repoPath: currentPath,
repoGitDirPath: filepath.Join(currentPath, ".git"),
repoName: "lazygit",
isBareRepo: false,
}
}
func GetRepoPaths(
cmd oscommands.ICmdObjBuilder,
version *GitVersion,
) (*RepoPaths, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
return GetRepoPathsForDir(cwd, cmd)
}
func GetRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
repoPaths, err := repoPathsForDir(dir, cmd)
if err != nil || !repoPaths.IsBareRepo() {
return repoPaths, err
}
// We're in a git dir rather than in a working tree, which usually just means
// somebody ran lazygit in the .git of an ordinary repo. git's convention is
// that a git dir called .git belongs to the directory holding it, so look
// there: if that is a working tree, it is the repo we were asked about, and
// there's no reason to make the user go up a directory and try again.
//
// The git dirs that aren't called .git keep the paths we have. A linked
// worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a
// working tree, but only the directory holding a .git tells us where, so we
// would be guessing. A bare repo's has none to find.
if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" {
return repoPaths, nil
}
pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd)
if err != nil || pathsFromWorkTree.IsBareRepo() {
return repoPaths, nil
}
return pathsFromWorkTree, nil
}
// repoPathsForDir asks git about the repo at dir, and reports a bare repo when
// there is no working tree there. Unlike GetRepoPathsForDir it never looks
// anywhere but dir, which is what keeps that one from going round in circles.
func repoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree")
if err != nil {
// --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]
// 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
// git dir lives under the superproject's .git/modules, but it is still the
// submodule's own common dir.
isMainWorktree := worktreeGitDirPath == repoGitDirPath
// If we're in a submodule, --show-superproject-working-tree will return a
// 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) == 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
// as the common git dir with a trailing "/.git" removed, which is this same
// derivation. So take the directory holding the common git dir. That is the
// main worktree of an ordinary repo, and of a bare one it is the directory
// its worktrees live in. It is not the main worktree of a repo that moved
// that elsewhere with core.worktree; there we end up naming the git dir's
// directory, which means that the repo name we display in the status panel
// isn't correct, and we start looking for .lazygit.yml in the wrong place.
// Both of those are not severe enough to justify the extra git call to get
// the real main worktree, so we accept this for this rather niche use case.
var repoPath string
if isMainWorktree || isSubmodule {
repoPath = worktreePath
} else {
repoPath = filepath.Dir(repoGitDirPath)
}
repoName := filepath.Base(repoPath)
return &RepoPaths{
worktreePath: worktreePath,
worktreeGitDirPath: worktreeGitDirPath,
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: repoName,
isBareRepo: false,
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
}, nil
}
// gitLocationEnvVars works out whether git can find the repo by itself when a
// command runs in its worktree, and if it can't, returns the environment that
// tells git where it is. See RepoPaths.GitLocationEnvVars.
func gitLocationEnvVars(
cmd oscommands.ICmdObjBuilder,
worktreePath string,
worktreeGitDirPath string,
) []string {
// The ordinary repo, where the git dir sits in the worktree. Both paths are
// git's own answers from the same invocation, so they are spelled alike and
// comparing them is safe.
if worktreeGitDirPath == filepath.Join(worktreePath, ".git") {
return nil
}
// A linked worktree or a submodule instead has a .git file naming its git
// dir, and git follows that just as happily. We could read the file, but the
// path in it may well name the same directory differently than git did
// above, so ask git to resolve it — from the worktree and nothing else.
discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir")
if err == nil && discoveredGitDirPath == worktreeGitDirPath {
return nil
}
return []string{
env.GitDirEnvVar + "=" + worktreeGitDirPath,
env.GitWorkTreeEnvVar + "=" + worktreePath,
}
}
// 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
}
// Asks git about the repo at dir. This is how we find our own repo, so it has
// to be answered the way git itself would answer it there, GIT_DIR and
// GIT_WORK_TREE included.
func callGitRevParseWithDir(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// 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...)))
}
func newGitRevParseCmd(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) *oscommands.CmdObj {
gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...)
if dir != "" {
gitRevParse.Dir(dir)
}
return cmd.New(gitRevParse.ToArgv()).DontLog()
}
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
res, err := gitCmd.RunWithOutput()
if err != nil {
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
}
return strings.TrimSpace(res), nil
}