mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Nothing needs to look up the branch of a repo by its path, so a slice indexed like the list of paths does the job, and its elements are plain strings instead of the values of type "any" that a sync.Map hands back. The next commits measure and truncate the branch name, which needs a string. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
package helpers
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
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/models"
|
|
"github.com/jesseduffield/lazygit/pkg/env"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type onNewRepoFn func(startArgs appTypes.StartArgs, contextKey types.ContextKey) error
|
|
|
|
// helps switch back and forth between repos
|
|
type ReposHelper struct {
|
|
c *HelperCommon
|
|
recordDirectoryHelper *RecordDirectoryHelper
|
|
onNewRepo onNewRepoFn
|
|
}
|
|
|
|
func NewRecentReposHelper(
|
|
c *HelperCommon,
|
|
recordDirectoryHelper *RecordDirectoryHelper,
|
|
onNewRepo onNewRepoFn,
|
|
) *ReposHelper {
|
|
return &ReposHelper{
|
|
c: c,
|
|
recordDirectoryHelper: recordDirectoryHelper,
|
|
onNewRepo: onNewRepo,
|
|
}
|
|
}
|
|
|
|
func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error {
|
|
// Check before pushing onto the repo-path stack, so a refused switch
|
|
// doesn't leave a stale entry there (which escape would later switch back
|
|
// to, needlessly reloading the current repo).
|
|
if self.switchRefusedBecauseBusy() {
|
|
return nil
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self.c.State().GetRepoPathStack().Push(types.RepoLocation{
|
|
Path: wd,
|
|
GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
|
|
})
|
|
|
|
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return self.c.Tr.BranchUnknown
|
|
}
|
|
|
|
func (self *ReposHelper) CreateRecentReposMenu() error {
|
|
// we'll show an empty panel if there are no recent repos
|
|
recentRepoPaths := []string{}
|
|
if len(self.c.GetAppState().RecentRepos) > 0 {
|
|
// we skip the first one because we're currently in it
|
|
recentRepoPaths = self.c.GetAppState().RecentRepos[1:]
|
|
}
|
|
|
|
currentBranches := make([]string, len(recentRepoPaths))
|
|
|
|
wg := sync.WaitGroup{}
|
|
wg.Add(len(recentRepoPaths))
|
|
|
|
for i, path := range recentRepoPaths {
|
|
go func() {
|
|
defer wg.Done()
|
|
currentBranches[i] = self.getCurrentBranch(path)
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
menuItems := lo.Map(recentRepoPaths, func(path string, i int) *types.MenuItem {
|
|
branchName := currentBranches[i]
|
|
if icons.IsIconEnabled() {
|
|
branchName = icons.BRANCH_ICON + " " + branchName
|
|
}
|
|
|
|
return &types.MenuItem{
|
|
LabelColumns: []string{
|
|
filepath.Base(path),
|
|
style.FgCyan.Sprint(branchName),
|
|
style.FgMagenta.Sprint(path),
|
|
},
|
|
OnPress: func() error {
|
|
// Check before clearing the stack, so a refused switch doesn't
|
|
// forget the submodule breadcrumb (which would leave escape
|
|
// unable to return to the parent repo).
|
|
if self.switchRefusedBecauseBusy() {
|
|
return nil
|
|
}
|
|
// if we were in a submodule, we want to forget about that stack of repos
|
|
// so that hitting escape in the new repo does nothing
|
|
self.c.State().GetRepoPathStack().Clear()
|
|
return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
|
},
|
|
}
|
|
})
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.RecentRepos,
|
|
Items: menuItems,
|
|
FilterAsYouType: true,
|
|
})
|
|
}
|
|
|
|
// SwitchToParentRepo switches back to the repo the current submodule was
|
|
// entered from (the top of the repo-path stack). Like the other callers that do
|
|
// work before switching, it checks for an in-flight operation *before* popping
|
|
// the stack, so a refused switch leaves the stack intact — otherwise the entry
|
|
// would be consumed and escape would no longer return to the parent once the
|
|
// operation finished. The caller must only call this when the stack is
|
|
// non-empty.
|
|
func (self *ReposHelper) SwitchToParentRepo() error {
|
|
if self.switchRefusedBecauseBusy() {
|
|
return nil
|
|
}
|
|
return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
|
}
|
|
|
|
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
|
if self.switchRefusedBecauseBusy() {
|
|
return nil
|
|
}
|
|
return self.switchTo(path, errMsg, contextKey)
|
|
}
|
|
|
|
// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch
|
|
// must be refused because a foreground git operation is in flight. Switching
|
|
// reassigns gui.git and the process cwd, so switching mid-operation would run
|
|
// the operation's remaining git commands against the wrong repo. Callers that
|
|
// do work before the switch (creating a worktree, recording the repo-path
|
|
// stack) check this up front, so they don't do that work only to have the
|
|
// switch refused; the switch itself (switchTo) is then unguarded.
|
|
func (self *ReposHelper) switchRefusedBecauseBusy() bool {
|
|
if self.c.GocuiGui().Busy() {
|
|
self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// switchTo switches lazygit to the repository (or worktree) at the given path,
|
|
// which git is expected to find from that path alone. That's true of every repo
|
|
// we switch to without having been there before.
|
|
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
|
return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey)
|
|
}
|
|
|
|
// switchToLocation switches lazygit to the repository (or worktree) at the
|
|
// given location. It runs synchronously on the UI thread: the switch swaps
|
|
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
|
|
// which the UI thread also reads, so doing it here rather than on a worker
|
|
// avoids racing those reads. The heavy data loading is still dispatched
|
|
// asynchronously by the refresh that onNewRepo kicks off.
|
|
//
|
|
// Everything from here on has to find the repo the way git does, from the
|
|
// directory we're about to change to, so the location's environment goes into
|
|
// the process env before we do. Usually that just clears whatever the repo
|
|
// we're leaving needed, but going back to a repo whose git dir isn't in its
|
|
// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the
|
|
// reason we remember the environment at all: nothing in the path leads to its
|
|
// git dir. On failure we put back what the repo we're staying in needs.
|
|
func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error {
|
|
originalPath, err := os.Getwd()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
originalGitLocationEnvVars := env.GetGitLocationEnvVars()
|
|
|
|
env.SetGitLocationEnvVars(location.GitLocationEnvVars)
|
|
|
|
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
|
|
self.c.LogCommand(msg, false)
|
|
|
|
if err := os.Chdir(location.Path); err != nil {
|
|
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
|
|
if os.IsNotExist(err) {
|
|
return errors.New(errMsg)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
|
|
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
|
|
if err := os.Chdir(originalPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
|
|
|
|
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
|
|
self.c.Log.Errorf("error recording current directory: %v", err)
|
|
}
|
|
|
|
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
if direnvResult.Blocked {
|
|
self.promptDirenvApproval(direnvResult.EnvrcPath)
|
|
return nil
|
|
}
|
|
|
|
return direnvResult.Err
|
|
}
|
|
|
|
// logDirenvResult writes whatever direnv emitted to the command log and the
|
|
// debug log; both happen for every load attempt regardless of outcome.
|
|
func (self *ReposHelper) logDirenvResult(result direnv.LoadResult) direnv.LoadResult {
|
|
if result.Message != "" {
|
|
self.c.LogCommand(result.Message, false)
|
|
}
|
|
if result.Err != nil {
|
|
self.c.Log.WithError(result.Err).Warn("direnv load failed")
|
|
}
|
|
return result
|
|
}
|
|
|
|
// promptDirenvApproval shows the user the contents of an unapproved .envrc
|
|
// and offers to run `direnv allow` for them. On confirm, we approve the
|
|
// file and re-run Load so the new env reaches subprocesses; on cancel we
|
|
// leave the env as-is (the previous repo's vars are already unloaded by
|
|
// the initial Load call, which is the correct state).
|
|
func (self *ReposHelper) promptDirenvApproval(envrcPath string) {
|
|
content, err := os.ReadFile(envrcPath)
|
|
if err != nil {
|
|
self.c.Log.WithError(err).Warn("could not read .envrc for approval prompt")
|
|
return
|
|
}
|
|
|
|
indented := " " + strings.ReplaceAll(strings.TrimRight(string(content), "\n"), "\n", "\n ")
|
|
prompt := utils.ResolvePlaceholderString(self.c.Tr.DirenvApprovalPrompt, map[string]string{
|
|
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
|
|
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
|
|
"content": indented,
|
|
})
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.DirenvApprovalTitle,
|
|
Prompt: prompt,
|
|
HandleConfirm: func() error {
|
|
if err := direnv.Allow(self.c.OS().Cmd, envrcPath); err != nil {
|
|
return err
|
|
}
|
|
return self.logDirenvResult(direnv.Load(self.c.OS().Cmd)).Err
|
|
},
|
|
})
|
|
}
|