package commands import ( "io" "io/ioutil" "os" "path/filepath" "strings" "time" "github.com/go-errors/errors" gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sirupsen/logrus" ) // this takes something like: // * (HEAD detached at 264fc6f5) // remotes // and returns '264fc6f5' as the second match const CurrentBranchNameRegex = `(?m)^\*.*?([^ ]*?)\)?$` // GitCommand is our main git interface type GitCommand struct { Log *logrus.Entry OSCommand *oscommands.OSCommand Repo *gogit.Repository Tr *i18n.TranslationSet Config config.AppConfigurer DotGitDir string onSuccessfulContinue func() error PatchManager *patch.PatchManager GitConfig git_config.IGitConfig // Push to current determines whether the user has configured to push to the remote branch of the same name as the current or not PushToCurrent bool // this is just a view that we write to when running certain commands. // Coincidentally at the moment it's the same view that OnRunCommand logs to // but that need not always be the case. GetCmdWriter func() io.Writer } // NewGitCommand it runs git commands func NewGitCommand( log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.TranslationSet, config config.AppConfigurer, gitConfig git_config.IGitConfig, ) (*GitCommand, error) { var repo *gogit.Repository pushToCurrent := gitConfig.Get("push.default") == "current" if err := navigateToRepoRootDirectory(os.Stat, os.Chdir); err != nil { return nil, err } var err error if repo, err = setupRepository(gogit.PlainOpen, tr.GitconfigParseErr); err != nil { return nil, err } dotGitDir, err := findDotGitDir(os.Stat, ioutil.ReadFile) if err != nil { return nil, err } gitCommand := &GitCommand{ Log: log, OSCommand: osCommand, Tr: tr, Repo: repo, Config: config, DotGitDir: dotGitDir, PushToCurrent: pushToCurrent, GitConfig: gitConfig, GetCmdWriter: func() io.Writer { return ioutil.Discard }, } gitCommand.PatchManager = patch.NewPatchManager(log, gitCommand.ApplyPatch, gitCommand.ShowFileDiff) return gitCommand, nil } func (c *GitCommand) WithSpan(span string) *GitCommand { // sometimes .WithSpan(span) will be called where span actually is empty, in // which case we don't need to log anything so we can just return early here // with the original struct if span == "" { return c } newGitCommand := &GitCommand{} *newGitCommand = *c newGitCommand.OSCommand = c.OSCommand.WithSpan(span) // NOTE: unlike the other things here which create shallow clones, this will // actually update the PatchManager on the original struct to have the new span. // This means each time we call ApplyPatch in PatchManager, we need to ensure // we've called .WithSpan() ahead of time with the new span value newGitCommand.PatchManager.ApplyPatch = newGitCommand.ApplyPatch return newGitCommand } func navigateToRepoRootDirectory(stat func(string) (os.FileInfo, error), chdir func(string) error) error { gitDir := env.GetGitDirEnv() if gitDir != "" { // we've been given the git directory explicitly so no need to navigate to it _, err := stat(gitDir) if err != nil { return utils.WrapError(err) } return nil } // we haven't been given the git dir explicitly so we assume it's in the current working directory as `.git/` (or an ancestor directory) for { _, err := stat(".git") if err == nil { return nil } if !os.IsNotExist(err) { return utils.WrapError(err) } if err = chdir(".."); err != nil { return utils.WrapError(err) } currentPath, err := os.Getwd() if err != nil { return err } atRoot := currentPath == filepath.Dir(currentPath) if atRoot { // we should never really land here: the code that creates GitCommand should // verify we're in a git directory return errors.New("Must open lazygit in a git repository") } } } // resolvePath takes a path containing a symlink and returns the true path func resolvePath(path string) (string, error) { l, err := os.Lstat(path) if err != nil { return "", err } if l.Mode()&os.ModeSymlink == 0 { return path, nil } return filepath.EvalSymlinks(path) } func setupRepository(openGitRepository func(string) (*gogit.Repository, error), gitConfigParseErrorStr string) (*gogit.Repository, error) { unresolvedPath := env.GetGitDirEnv() if unresolvedPath == "" { var err error unresolvedPath, err = os.Getwd() if err != nil { return nil, err } } path, err := resolvePath(unresolvedPath) if err != nil { return nil, err } repository, err := openGitRepository(path) if err != nil { if strings.Contains(err.Error(), `unquoted '\' must be followed by new line`) { return nil, errors.New(gitConfigParseErrorStr) } return nil, err } return repository, err } func findDotGitDir(stat func(string) (os.FileInfo, error), readFile func(filename string) ([]byte, error)) (string, error) { if env.GetGitDirEnv() != "" { return env.GetGitDirEnv(), nil } f, err := stat(".git") if err != nil { return "", err } if f.IsDir() { return ".git", nil } fileBytes, err := readFile(".git") if err != nil { return "", err } fileContent := string(fileBytes) if !strings.HasPrefix(fileContent, "gitdir: ") { return "", errors.New(".git is a file which suggests we are in a submodule but the file's contents do not contain a gitdir pointing to the actual .git directory") } return strings.TrimSpace(strings.TrimPrefix(fileContent, "gitdir: ")), nil } func VerifyInGitRepo(osCommand *oscommands.OSCommand) error { return osCommand.RunCommand("git rev-parse --git-dir") } func (c *GitCommand) RunCommand(formatString string, formatArgs ...interface{}) error { _, err := c.RunCommandWithOutput(formatString, formatArgs...) return err } func (c *GitCommand) RunCommandWithOutput(formatString string, formatArgs ...interface{}) (string, error) { // TODO: have this retry logic in other places we run the command waitTime := 50 * time.Millisecond retryCount := 5 attempt := 0 for { output, err := c.OSCommand.RunCommandWithOutput(formatString, formatArgs...) if err != nil { // if we have an error based on the index lock, we should wait a bit and then retry if strings.Contains(output, ".git/index.lock") { c.Log.Error(output) c.Log.Info("index.lock prevented command from running. Retrying command after a small wait") attempt++ time.Sleep(waitTime) if attempt < retryCount { continue } } } return output, err } } func (c *GitCommand) NewCmdObjFromStr(cmdStr string) oscommands.ICmdObj { return c.OSCommand.NewCmdObjFromStr(cmdStr).AddEnvVars("GIT_OPTIONAL_LOCKS=0") }