From 72af7e41778bca93d82fa668641f515fba1d92bc Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 29 Sep 2020 20:03:39 +1000 Subject: [PATCH] factor out code from git.go --- pkg/commands/branches.go | 157 +++ pkg/commands/commits.go | 93 ++ pkg/commands/config.go | 52 + pkg/commands/files.go | 270 ++++ pkg/commands/git.go | 1173 +---------------- pkg/commands/git_test.go | 2 +- ...ch_list_builder.go => loading_branches.go} | 0 ...mit_list_builder.go => loading_commits.go} | 0 ...uilder_test.go => loading_commits_test.go} | 0 pkg/commands/loading_files.go | 9 +- pkg/commands/patch_rebases.go | 28 +- pkg/commands/rebasing.go | 287 ++++ pkg/commands/remotes.go | 40 + pkg/commands/stash_entries.go | 58 + pkg/commands/status.go | 48 + pkg/commands/sync.go | 74 ++ pkg/commands/tags.go | 13 + pkg/gui/rebase_options_panel.go | 2 +- pkg/gui/remotes_panel.go | 6 +- 19 files changed, 1184 insertions(+), 1128 deletions(-) create mode 100644 pkg/commands/branches.go create mode 100644 pkg/commands/commits.go create mode 100644 pkg/commands/config.go create mode 100644 pkg/commands/files.go rename pkg/commands/{branch_list_builder.go => loading_branches.go} (100%) rename pkg/commands/{commit_list_builder.go => loading_commits.go} (100%) rename pkg/commands/{commit_list_builder_test.go => loading_commits_test.go} (100%) create mode 100644 pkg/commands/rebasing.go create mode 100644 pkg/commands/remotes.go create mode 100644 pkg/commands/stash_entries.go create mode 100644 pkg/commands/status.go create mode 100644 pkg/commands/sync.go create mode 100644 pkg/commands/tags.go diff --git a/pkg/commands/branches.go b/pkg/commands/branches.go new file mode 100644 index 000000000..2da076e3a --- /dev/null +++ b/pkg/commands/branches.go @@ -0,0 +1,157 @@ +package commands + +import ( + "fmt" + "regexp" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// NewBranch create new branch +func (c *GitCommand) NewBranch(name string, base string) error { + return c.OSCommand.RunCommand("git checkout -b %s %s", name, base) +} + +// CurrentBranchName get the current branch name and displayname. +// the first returned string is the name and the second is the displayname +// e.g. name is 123asdf and displayname is '(HEAD detached at 123asdf)' +func (c *GitCommand) CurrentBranchName() (string, string, error) { + branchName, err := c.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD") + if err == nil && branchName != "HEAD\n" { + trimmedBranchName := strings.TrimSpace(branchName) + return trimmedBranchName, trimmedBranchName, nil + } + output, err := c.OSCommand.RunCommandWithOutput("git branch --contains") + if err != nil { + return "", "", err + } + for _, line := range utils.SplitLines(output) { + re := regexp.MustCompile(CurrentBranchNameRegex) + match := re.FindStringSubmatch(line) + if len(match) > 0 { + branchName = match[1] + displayBranchName := match[0][2:] + return branchName, displayBranchName, nil + } + } + return "HEAD", "HEAD", nil +} + +// DeleteBranch delete branch +func (c *GitCommand) DeleteBranch(branch string, force bool) error { + command := "git branch -d" + + if force { + command = "git branch -D" + } + + return c.OSCommand.RunCommand("%s %s", command, branch) +} + +// Checkout checks out a branch (or commit), with --force if you set the force arg to true +type CheckoutOptions struct { + Force bool + EnvVars []string +} + +func (c *GitCommand) Checkout(branch string, options CheckoutOptions) error { + forceArg := "" + if options.Force { + forceArg = "--force " + } + return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git checkout %s %s", forceArg, branch), oscommands.RunCommandOptions{EnvVars: options.EnvVars}) +} + +// GetBranchGraph gets the color-formatted graph of the log for the given branch +// Currently it limits the result to 100 commits, but when we get async stuff +// working we can do lazy loading +func (c *GitCommand) GetBranchGraph(branchName string) (string, error) { + cmdStr := c.GetBranchGraphCmdStr(branchName) + return c.OSCommand.RunCommandWithOutput(cmdStr) +} + +func (c *GitCommand) GetUpstreamForBranch(branchName string) (string, error) { + output, err := c.OSCommand.RunCommandWithOutput("git rev-parse --abbrev-ref --symbolic-full-name %s@{u}", branchName) + return strings.TrimSpace(output), err +} + +func (c *GitCommand) GetBranchGraphCmdStr(branchName string) string { + branchLogCmdTemplate := c.Config.GetUserConfig().GetString("git.branchLogCmd") + templateValues := map[string]string{ + "branchName": branchName, + } + return utils.ResolvePlaceholderString(branchLogCmdTemplate, templateValues) +} + +func (c *GitCommand) SetUpstreamBranch(upstream string) error { + return c.OSCommand.RunCommand("git branch -u %s", upstream) +} + +func (c *GitCommand) SetBranchUpstream(remoteName string, remoteBranchName string, branchName string) error { + return c.OSCommand.RunCommand("git branch --set-upstream-to=%s/%s %s", remoteName, remoteBranchName, branchName) +} + +func (c *GitCommand) GetCurrentBranchUpstreamDifferenceCount() (string, string) { + return c.GetCommitDifferences("HEAD", "HEAD@{u}") +} + +func (c *GitCommand) GetBranchUpstreamDifferenceCount(branchName string) (string, string) { + return c.GetCommitDifferences(branchName, branchName+"@{u}") +} + +// GetCommitDifferences checks how many pushables/pullables there are for the +// current branch +func (c *GitCommand) GetCommitDifferences(from, to string) (string, string) { + command := "git rev-list %s..%s --count" + pushableCount, err := c.OSCommand.RunCommandWithOutput(command, to, from) + if err != nil { + return "?", "?" + } + pullableCount, err := c.OSCommand.RunCommandWithOutput(command, from, to) + if err != nil { + return "?", "?" + } + return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount) +} + +type MergeOpts struct { + FastForwardOnly bool +} + +// Merge merge +func (c *GitCommand) Merge(branchName string, opts MergeOpts) error { + mergeArgs := c.Config.GetUserConfig().GetString("git.merging.args") + + command := fmt.Sprintf("git merge --no-edit %s %s", mergeArgs, branchName) + if opts.FastForwardOnly { + command = fmt.Sprintf("%s --ff-only", command) + } + + return c.OSCommand.RunCommand(command) +} + +// AbortMerge abort merge +func (c *GitCommand) AbortMerge() error { + return c.OSCommand.RunCommand("git merge --abort") +} + +func (c *GitCommand) IsHeadDetached() bool { + err := c.OSCommand.RunCommand("git symbolic-ref -q HEAD") + return err != nil +} + +// ResetHardHead runs `git reset --hard` +func (c *GitCommand) ResetHard(ref string) error { + return c.OSCommand.RunCommand("git reset --hard " + ref) +} + +// ResetSoft runs `git reset --soft HEAD` +func (c *GitCommand) ResetSoft(ref string) error { + return c.OSCommand.RunCommand("git reset --soft " + ref) +} + +func (c *GitCommand) RenameBranch(oldName string, newName string) error { + return c.OSCommand.RunCommand("git branch --move %s %s", oldName, newName) +} diff --git a/pkg/commands/commits.go b/pkg/commands/commits.go new file mode 100644 index 000000000..7fc4cc2a9 --- /dev/null +++ b/pkg/commands/commits.go @@ -0,0 +1,93 @@ +package commands + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/models" +) + +// RenameCommit renames the topmost commit with the given name +func (c *GitCommand) RenameCommit(name string) error { + return c.OSCommand.RunCommand("git commit --allow-empty --amend -m %s", c.OSCommand.Quote(name)) +} + +// ResetToCommit reset to commit +func (c *GitCommand) ResetToCommit(sha string, strength string, options oscommands.RunCommandOptions) error { + return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git reset --%s %s", strength, sha), options) +} + +// Commit commits to git +func (c *GitCommand) Commit(message string, flags string) (*exec.Cmd, error) { + command := fmt.Sprintf("git commit %s -m %s", flags, strconv.Quote(message)) + if c.usingGpg() { + return c.OSCommand.ShellCommandFromString(command), nil + } + + return nil, c.OSCommand.RunCommand(command) +} + +// Get the subject of the HEAD commit +func (c *GitCommand) GetHeadCommitMessage() (string, error) { + cmdStr := "git log -1 --pretty=%s" + message, err := c.OSCommand.RunCommandWithOutput(cmdStr) + return strings.TrimSpace(message), err +} + +func (c *GitCommand) GetCommitMessage(commitSha string) (string, error) { + cmdStr := "git rev-list --format=%B --max-count=1 " + commitSha + messageWithHeader, err := c.OSCommand.RunCommandWithOutput(cmdStr) + message := strings.Join(strings.SplitAfter(messageWithHeader, "\n")[1:], "\n") + return strings.TrimSpace(message), err +} + +// AmendHead amends HEAD with whatever is staged in your working tree +func (c *GitCommand) AmendHead() (*exec.Cmd, error) { + command := "git commit --amend --no-edit --allow-empty" + if c.usingGpg() { + return c.OSCommand.ShellCommandFromString(command), nil + } + + return nil, c.OSCommand.RunCommand(command) +} + +// PrepareCommitAmendSubProcess prepares a subprocess for `git commit --amend --allow-empty` +func (c *GitCommand) PrepareCommitAmendSubProcess() *exec.Cmd { + return c.OSCommand.PrepareSubProcess("git", "commit", "--amend", "--allow-empty") +} + +func (c *GitCommand) ShowCmdStr(sha string, filterPath string) string { + filterPathArg := "" + if filterPath != "" { + filterPathArg = fmt.Sprintf(" -- %s", c.OSCommand.Quote(filterPath)) + } + return fmt.Sprintf("git show --submodule --color=%s --no-renames --stat -p %s %s", c.colorArg(), sha, filterPathArg) +} + +// Revert reverts the selected commit by sha +func (c *GitCommand) Revert(sha string) error { + return c.OSCommand.RunCommand("git revert %s", sha) +} + +// CherryPickCommits begins an interactive rebase with the given shas being cherry picked onto HEAD +func (c *GitCommand) CherryPickCommits(commits []*models.Commit) error { + todo := "" + for _, commit := range commits { + todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo + } + + cmd, err := c.PrepareInteractiveRebaseCommand("HEAD", todo, false) + if err != nil { + return err + } + + return c.OSCommand.RunPreparedCommand(cmd) +} + +// CreateFixupCommit creates a commit that fixes up a previous commit +func (c *GitCommand) CreateFixupCommit(sha string) error { + return c.OSCommand.RunCommand("git commit --fixup=%s", sha) +} diff --git a/pkg/commands/config.go b/pkg/commands/config.go new file mode 100644 index 000000000..018de2724 --- /dev/null +++ b/pkg/commands/config.go @@ -0,0 +1,52 @@ +package commands + +import ( + "os" + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" +) + +func (c *GitCommand) ConfiguredPager() string { + if os.Getenv("GIT_PAGER") != "" { + return os.Getenv("GIT_PAGER") + } + if os.Getenv("PAGER") != "" { + return os.Getenv("PAGER") + } + output, err := c.OSCommand.RunCommandWithOutput("git config --get-all core.pager") + if err != nil { + return "" + } + trimmedOutput := strings.TrimSpace(output) + return strings.Split(trimmedOutput, "\n")[0] +} + +func (c *GitCommand) GetPager(width int) string { + useConfig := c.Config.GetUserConfig().GetBool("git.paging.useConfig") + if useConfig { + pager := c.ConfiguredPager() + return strings.Split(pager, "| less")[0] + } + + templateValues := map[string]string{ + "columnWidth": strconv.Itoa(width/2 - 6), + } + + pagerTemplate := c.Config.GetUserConfig().GetString("git.paging.pager") + return utils.ResolvePlaceholderString(pagerTemplate, templateValues) +} + +func (c *GitCommand) colorArg() string { + return c.Config.GetUserConfig().GetString("git.paging.colorArg") +} + +func (c *GitCommand) GetConfigValue(key string) string { + output, err := c.OSCommand.RunCommandWithOutput("git config --get %s", key) + if err != nil { + // looks like this returns an error if there is no matching value which we're okay with + return "" + } + return strings.TrimSpace(output) +} diff --git a/pkg/commands/files.go b/pkg/commands/files.go new file mode 100644 index 000000000..e52de446b --- /dev/null +++ b/pkg/commands/files.go @@ -0,0 +1,270 @@ +package commands + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/models" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// CatFile obtains the content of a file +func (c *GitCommand) CatFile(fileName string) (string, error) { + return c.OSCommand.RunCommandWithOutput("%s %s", c.OSCommand.Platform.CatCmd, c.OSCommand.Quote(fileName)) +} + +// StageFile stages a file +func (c *GitCommand) StageFile(fileName string) error { + // renamed files look like "file1 -> file2" + fileNames := strings.Split(fileName, " -> ") + return c.OSCommand.RunCommand("git add %s", c.OSCommand.Quote(fileNames[len(fileNames)-1])) +} + +// StageAll stages all files +func (c *GitCommand) StageAll() error { + return c.OSCommand.RunCommand("git add -A") +} + +// UnstageAll stages all files +func (c *GitCommand) UnstageAll() error { + return c.OSCommand.RunCommand("git reset") +} + +// UnStageFile unstages a file +func (c *GitCommand) UnStageFile(fileName string, tracked bool) error { + command := "git rm --cached %s" + if tracked { + command = "git reset HEAD %s" + } + + // renamed files look like "file1 -> file2" + fileNames := strings.Split(fileName, " -> ") + for _, name := range fileNames { + if err := c.OSCommand.RunCommand(command, c.OSCommand.Quote(name)); err != nil { + return err + } + } + return nil +} + +func (c *GitCommand) BeforeAndAfterFileForRename(file *models.File) (*models.File, *models.File, error) { + + if !file.IsRename() { + return nil, nil, errors.New("Expected renamed file") + } + + // we've got a file that represents a rename from one file to another. Unfortunately + // our File abstraction fails to consider this case, so here we will refetch + // all files, passing the --no-renames flag and then recursively call the function + // again for the before file and after file. At some point we should fix the abstraction itself + + split := strings.Split(file.Name, " -> ") + filesWithoutRenames := c.GetStatusFiles(GetStatusFileOptions{NoRenames: true}) + var beforeFile *models.File + var afterFile *models.File + for _, f := range filesWithoutRenames { + if f.Name == split[0] { + beforeFile = f + } + if f.Name == split[1] { + afterFile = f + } + } + + if beforeFile == nil || afterFile == nil { + return nil, nil, errors.New("Could not find deleted file or new file for file rename") + } + + if beforeFile.IsRename() || afterFile.IsRename() { + // probably won't happen but we want to ensure we don't get an infinite loop + return nil, nil, errors.New("Nested rename found") + } + + return beforeFile, afterFile, nil +} + +// DiscardAllFileChanges directly +func (c *GitCommand) DiscardAllFileChanges(file *models.File) error { + if file.IsRename() { + beforeFile, afterFile, err := c.BeforeAndAfterFileForRename(file) + if err != nil { + return err + } + + if err := c.DiscardAllFileChanges(beforeFile); err != nil { + return err + } + + if err := c.DiscardAllFileChanges(afterFile); err != nil { + return err + } + + return nil + } + + // if the file isn't tracked, we assume you want to delete it + quotedFileName := c.OSCommand.Quote(file.Name) + if file.HasStagedChanges || file.HasMergeConflicts { + if err := c.OSCommand.RunCommand("git reset -- %s", quotedFileName); err != nil { + return err + } + } + + if !file.Tracked { + return c.removeFile(file.Name) + } + return c.DiscardUnstagedFileChanges(file) +} + +// DiscardUnstagedFileChanges directly +func (c *GitCommand) DiscardUnstagedFileChanges(file *models.File) error { + quotedFileName := c.OSCommand.Quote(file.Name) + return c.OSCommand.RunCommand("git checkout -- %s", quotedFileName) +} + +// Ignore adds a file to the gitignore for the repo +func (c *GitCommand) Ignore(filename string) error { + return c.OSCommand.AppendLineToFile(".gitignore", filename) +} + +// WorktreeFileDiff returns the diff of a file +func (c *GitCommand) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { + // for now we assume an error means the file was deleted + s, _ := c.OSCommand.RunCommandWithOutput(c.WorktreeFileDiffCmdStr(file, plain, cached)) + return s +} + +func (c *GitCommand) WorktreeFileDiffCmdStr(file *models.File, plain bool, cached bool) string { + cachedArg := "" + trackedArg := "--" + colorArg := c.colorArg() + split := strings.Split(file.Name, " -> ") // in case of a renamed file we get the new filename + fileName := c.OSCommand.Quote(split[len(split)-1]) + if cached { + cachedArg = "--cached" + } + if !file.Tracked && !file.HasStagedChanges && !cached { + trackedArg = "--no-index /dev/null" + } + if plain { + colorArg = "never" + } + + return fmt.Sprintf("git diff --submodule --no-ext-diff --color=%s %s %s %s", colorArg, cachedArg, trackedArg, fileName) +} + +func (c *GitCommand) ApplyPatch(patch string, flags ...string) error { + filepath := filepath.Join(c.Config.GetUserConfigDir(), utils.GetCurrentRepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch") + c.Log.Infof("saving temporary patch to %s", filepath) + if err := c.OSCommand.CreateFileWithContent(filepath, patch); err != nil { + return err + } + + flagStr := "" + for _, flag := range flags { + flagStr += " --" + flag + } + + return c.OSCommand.RunCommand("git apply %s %s", flagStr, c.OSCommand.Quote(filepath)) +} + +// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc +// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. +func (c *GitCommand) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) { + cmdStr := c.ShowFileDiffCmdStr(from, to, reverse, fileName, plain) + return c.OSCommand.RunCommandWithOutput(cmdStr) +} + +func (c *GitCommand) ShowFileDiffCmdStr(from string, to string, reverse bool, fileName string, plain bool) string { + colorArg := c.colorArg() + if plain { + colorArg = "never" + } + + reverseFlag := "" + if reverse { + reverseFlag = " -R " + } + + return fmt.Sprintf("git diff --submodule --no-ext-diff --no-renames --color=%s %s %s %s -- %s", colorArg, from, to, reverseFlag, fileName) +} + +// CheckoutFile checks out the file for the given commit +func (c *GitCommand) CheckoutFile(commitSha, fileName string) error { + return c.OSCommand.RunCommand("git checkout %s %s", commitSha, fileName) +} + +// DiscardOldFileChanges discards changes to a file from an old commit +func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex int, fileName string) error { + if err := c.BeginInteractiveRebaseForCommit(commits, commitIndex); err != nil { + return err + } + + // check if file exists in previous commit (this command returns an error if the file doesn't exist) + if err := c.OSCommand.RunCommand("git cat-file -e HEAD^:%s", fileName); err != nil { + if err := c.OSCommand.Remove(fileName); err != nil { + return err + } + if err := c.StageFile(fileName); err != nil { + return err + } + } else if err := c.CheckoutFile("HEAD^", fileName); err != nil { + return err + } + + // amend the commit + cmd, err := c.AmendHead() + if cmd != nil { + return errors.New("received unexpected pointer to cmd") + } + if err != nil { + return err + } + + // continue + return c.GenericMergeOrRebaseAction("rebase", "continue") +} + +// DiscardAnyUnstagedFileChanges discards any unstages file changes via `git checkout -- .` +func (c *GitCommand) DiscardAnyUnstagedFileChanges() error { + return c.OSCommand.RunCommand("git checkout -- .") +} + +// RemoveTrackedFiles will delete the given file(s) even if they are currently tracked +func (c *GitCommand) RemoveTrackedFiles(name string) error { + return c.OSCommand.RunCommand("git rm -r --cached %s", name) +} + +// RemoveUntrackedFiles runs `git clean -fd` +func (c *GitCommand) RemoveUntrackedFiles() error { + return c.OSCommand.RunCommand("git clean -fd") +} + +// ResetAndClean removes all unstaged changes and removes all untracked files +func (c *GitCommand) ResetAndClean() error { + submoduleConfigs, err := c.GetSubmoduleConfigs() + if err != nil { + return err + } + + if len(submoduleConfigs) > 0 { + for _, config := range submoduleConfigs { + if err := c.SubmoduleStash(config); err != nil { + return err + } + } + + if err := c.SubmoduleUpdateAll(); err != nil { + return err + } + } + + if err := c.ResetHard("HEAD"); err != nil { + return err + } + + return c.RemoveUntrackedFiles() +} diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 278b1d557..ab2819f75 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -1,17 +1,10 @@ package commands import ( - "fmt" "io/ioutil" "os" - "os/exec" "path/filepath" - "regexp" - "strconv" "strings" - "time" - - "github.com/mgutz/str" "github.com/go-errors/errors" @@ -21,7 +14,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/jesseduffield/lazygit/pkg/models" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sirupsen/logrus" gitconfig "github.com/tcnksm/go-gitconfig" @@ -33,6 +25,72 @@ import ( // 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.Localizer + Config config.AppConfigurer + getGlobalGitConfig func(string) (string, error) + getLocalGitConfig func(string) (string, error) + removeFile func(string) error + DotGitDir string + onSuccessfulContinue func() error + PatchManager *patch.PatchManager + + // 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 +} + +// NewGitCommand it runs git commands +func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.Localizer, config config.AppConfigurer) (*GitCommand, error) { + var repo *gogit.Repository + + // see what our default push behaviour is + output, err := osCommand.RunCommandWithOutput("git config --get push.default") + pushToCurrent := false + if err != nil { + log.Errorf("error reading git config: %v", err) + } else { + pushToCurrent = strings.TrimSpace(output) == "current" + } + + if err := verifyInGitRepo(osCommand.RunCommand); err != nil { + return nil, err + } + + if err := navigateToRepoRootDirectory(os.Stat, os.Chdir); err != nil { + return nil, err + } + + if repo, err = setupRepository(gogit.PlainOpen, tr.SLocalize); 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, + getGlobalGitConfig: gitconfig.Global, + getLocalGitConfig: gitconfig.Local, + removeFile: os.RemoveAll, + DotGitDir: dotGitDir, + PushToCurrent: pushToCurrent, + } + + gitCommand.PatchManager = patch.NewPatchManager(log, gitCommand.ApplyPatch, gitCommand.ShowFileDiff) + + return gitCommand, nil +} + func verifyInGitRepo(runCmd func(string, ...interface{}) error) error { return runCmd("git status") } @@ -110,72 +168,6 @@ func setupRepository(openGitRepository func(string) (*gogit.Repository, error), return repository, err } -// GitCommand is our main git interface -type GitCommand struct { - Log *logrus.Entry - OSCommand *oscommands.OSCommand - Repo *gogit.Repository - Tr *i18n.Localizer - Config config.AppConfigurer - getGlobalGitConfig func(string) (string, error) - getLocalGitConfig func(string) (string, error) - removeFile func(string) error - DotGitDir string - onSuccessfulContinue func() error - PatchManager *patch.PatchManager - - // 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 -} - -// NewGitCommand it runs git commands -func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.Localizer, config config.AppConfigurer) (*GitCommand, error) { - var repo *gogit.Repository - - // see what our default push behaviour is - output, err := osCommand.RunCommandWithOutput("git config --get push.default") - pushToCurrent := false - if err != nil { - log.Errorf("error reading git config: %v", err) - } else { - pushToCurrent = strings.TrimSpace(output) == "current" - } - - if err := verifyInGitRepo(osCommand.RunCommand); err != nil { - return nil, err - } - - if err := navigateToRepoRootDirectory(os.Stat, os.Chdir); err != nil { - return nil, err - } - - if repo, err = setupRepository(gogit.PlainOpen, tr.SLocalize); 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, - getGlobalGitConfig: gitconfig.Global, - getLocalGitConfig: gitconfig.Local, - removeFile: os.RemoveAll, - DotGitDir: dotGitDir, - PushToCurrent: pushToCurrent, - } - - gitCommand.PatchManager = patch.NewPatchManager(log, gitCommand.ApplyPatch, gitCommand.ShowFileDiff) - - return gitCommand, nil -} - func findDotGitDir(stat func(string) (os.FileInfo, error), readFile func(filename string) ([]byte, error)) (string, error) { if env.GetGitDirEnv() != "" { return env.GetGitDirEnv(), nil @@ -200,1036 +192,3 @@ func findDotGitDir(stat func(string) (os.FileInfo, error), readFile func(filenam } return strings.TrimSpace(strings.TrimPrefix(fileContent, "gitdir: ")), nil } - -// GetStashEntryDiff stash diff -func (c *GitCommand) ShowStashEntryCmdStr(index int) string { - return fmt.Sprintf("git stash show -p --stat --color=%s stash@{%d}", c.colorArg(), index) -} - -// GetStatusFiles git status files -type GetStatusFileOptions struct { - NoRenames bool -} - -func (c *GitCommand) GetConfigValue(key string) string { - output, _ := c.OSCommand.RunCommandWithOutput("git config --get %s", key) - // looks like this returns an error if there is no matching value which we're okay with - return strings.TrimSpace(output) -} - -// StashDo modify stash -func (c *GitCommand) StashDo(index int, method string) error { - return c.OSCommand.RunCommand("git stash %s stash@{%d}", method, index) -} - -// StashSave save stash -// TODO: before calling this, check if there is anything to save -func (c *GitCommand) StashSave(message string) error { - return c.OSCommand.RunCommand("git stash save %s", c.OSCommand.Quote(message)) -} - -func includesInt(list []int, a int) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} - -// ResetAndClean removes all unstaged changes and removes all untracked files -func (c *GitCommand) ResetAndClean() error { - submoduleConfigs, err := c.GetSubmoduleConfigs() - if err != nil { - return err - } - - if len(submoduleConfigs) > 0 { - for _, config := range submoduleConfigs { - if err := c.SubmoduleStash(config); err != nil { - return err - } - } - - if err := c.SubmoduleUpdateAll(); err != nil { - return err - } - } - - if err := c.ResetHard("HEAD"); err != nil { - return err - } - - return c.RemoveUntrackedFiles() -} - -func (c *GitCommand) GetCurrentBranchUpstreamDifferenceCount() (string, string) { - return c.GetCommitDifferences("HEAD", "HEAD@{u}") -} - -func (c *GitCommand) GetBranchUpstreamDifferenceCount(branchName string) (string, string) { - return c.GetCommitDifferences(branchName, branchName+"@{u}") -} - -// GetCommitDifferences checks how many pushables/pullables there are for the -// current branch -func (c *GitCommand) GetCommitDifferences(from, to string) (string, string) { - command := "git rev-list %s..%s --count" - pushableCount, err := c.OSCommand.RunCommandWithOutput(command, to, from) - if err != nil { - return "?", "?" - } - pullableCount, err := c.OSCommand.RunCommandWithOutput(command, from, to) - if err != nil { - return "?", "?" - } - return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount) -} - -// RenameCommit renames the topmost commit with the given name -func (c *GitCommand) RenameCommit(name string) error { - return c.OSCommand.RunCommand("git commit --allow-empty --amend -m %s", c.OSCommand.Quote(name)) -} - -// RebaseBranch interactive rebases onto a branch -func (c *GitCommand) RebaseBranch(branchName string) error { - cmd, err := c.PrepareInteractiveRebaseCommand(branchName, "", false) - if err != nil { - return err - } - - return c.OSCommand.RunPreparedCommand(cmd) -} - -type FetchOptions struct { - PromptUserForCredential func(string) string - RemoteName string - BranchName string -} - -// Fetch fetch git repo -func (c *GitCommand) Fetch(opts FetchOptions) error { - command := "git fetch" - - if opts.RemoteName != "" { - command = fmt.Sprintf("%s %s", command, opts.RemoteName) - } - if opts.BranchName != "" { - command = fmt.Sprintf("%s %s", command, opts.BranchName) - } - - return c.OSCommand.DetectUnamePass(command, func(question string) string { - if opts.PromptUserForCredential != nil { - return opts.PromptUserForCredential(question) - } - return "\n" - }) -} - -// ResetToCommit reset to commit -func (c *GitCommand) ResetToCommit(sha string, strength string, options oscommands.RunCommandOptions) error { - return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git reset --%s %s", strength, sha), options) -} - -// NewBranch create new branch -func (c *GitCommand) NewBranch(name string, base string) error { - return c.OSCommand.RunCommand("git checkout -b %s %s", name, base) -} - -// CurrentBranchName get the current branch name and displayname. -// the first returned string is the name and the second is the displayname -// e.g. name is 123asdf and displayname is '(HEAD detached at 123asdf)' -func (c *GitCommand) CurrentBranchName() (string, string, error) { - branchName, err := c.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD") - if err == nil && branchName != "HEAD\n" { - trimmedBranchName := strings.TrimSpace(branchName) - return trimmedBranchName, trimmedBranchName, nil - } - output, err := c.OSCommand.RunCommandWithOutput("git branch --contains") - if err != nil { - return "", "", err - } - for _, line := range utils.SplitLines(output) { - re := regexp.MustCompile(CurrentBranchNameRegex) - match := re.FindStringSubmatch(line) - if len(match) > 0 { - branchName = match[1] - displayBranchName := match[0][2:] - return branchName, displayBranchName, nil - } - } - return "HEAD", "HEAD", nil -} - -// DeleteBranch delete branch -func (c *GitCommand) DeleteBranch(branch string, force bool) error { - command := "git branch -d" - - if force { - command = "git branch -D" - } - - return c.OSCommand.RunCommand("%s %s", command, branch) -} - -type MergeOpts struct { - FastForwardOnly bool -} - -// Merge merge -func (c *GitCommand) Merge(branchName string, opts MergeOpts) error { - mergeArgs := c.Config.GetUserConfig().GetString("git.merging.args") - - command := fmt.Sprintf("git merge --no-edit %s %s", mergeArgs, branchName) - if opts.FastForwardOnly { - command = fmt.Sprintf("%s --ff-only", command) - } - - return c.OSCommand.RunCommand(command) -} - -// AbortMerge abort merge -func (c *GitCommand) AbortMerge() error { - return c.OSCommand.RunCommand("git merge --abort") -} - -// usingGpg tells us whether the user has gpg enabled so that we can know -// whether we need to run a subprocess to allow them to enter their password -func (c *GitCommand) usingGpg() bool { - overrideGpg := c.Config.GetUserConfig().GetBool("git.overrideGpg") - if overrideGpg { - return false - } - - gpgsign, _ := c.getLocalGitConfig("commit.gpgsign") - if gpgsign == "" { - gpgsign, _ = c.getGlobalGitConfig("commit.gpgsign") - } - value := strings.ToLower(gpgsign) - - return value == "true" || value == "1" || value == "yes" || value == "on" -} - -// Commit commits to git -func (c *GitCommand) Commit(message string, flags string) (*exec.Cmd, error) { - command := fmt.Sprintf("git commit %s -m %s", flags, strconv.Quote(message)) - if c.usingGpg() { - return c.OSCommand.ShellCommandFromString(command), nil - } - - return nil, c.OSCommand.RunCommand(command) -} - -// Get the subject of the HEAD commit -func (c *GitCommand) GetHeadCommitMessage() (string, error) { - cmdStr := "git log -1 --pretty=%s" - message, err := c.OSCommand.RunCommandWithOutput(cmdStr) - return strings.TrimSpace(message), err -} - -func (c *GitCommand) GetCommitMessage(commitSha string) (string, error) { - cmdStr := "git rev-list --format=%B --max-count=1 " + commitSha - messageWithHeader, err := c.OSCommand.RunCommandWithOutput(cmdStr) - message := strings.Join(strings.SplitAfter(messageWithHeader, "\n")[1:], "\n") - return strings.TrimSpace(message), err -} - -// AmendHead amends HEAD with whatever is staged in your working tree -func (c *GitCommand) AmendHead() (*exec.Cmd, error) { - command := "git commit --amend --no-edit --allow-empty" - if c.usingGpg() { - return c.OSCommand.ShellCommandFromString(command), nil - } - - return nil, c.OSCommand.RunCommand(command) -} - -// Push pushes to a branch -func (c *GitCommand) Push(branchName string, force bool, upstream string, args string, promptUserForCredential func(string) string) error { - forceFlag := "" - if force { - forceFlag = "--force-with-lease" - } - - setUpstreamArg := "" - if upstream != "" { - setUpstreamArg = "--set-upstream " + upstream - } - - cmd := fmt.Sprintf("git push --follow-tags %s %s %s", forceFlag, setUpstreamArg, args) - return c.OSCommand.DetectUnamePass(cmd, promptUserForCredential) -} - -// CatFile obtains the content of a file -func (c *GitCommand) CatFile(fileName string) (string, error) { - return c.OSCommand.RunCommandWithOutput("%s %s", c.OSCommand.Platform.CatCmd, c.OSCommand.Quote(fileName)) -} - -// StageFile stages a file -func (c *GitCommand) StageFile(fileName string) error { - // renamed files look like "file1 -> file2" - fileNames := strings.Split(fileName, " -> ") - return c.OSCommand.RunCommand("git add %s", c.OSCommand.Quote(fileNames[len(fileNames)-1])) -} - -// StageAll stages all files -func (c *GitCommand) StageAll() error { - return c.OSCommand.RunCommand("git add -A") -} - -// UnstageAll stages all files -func (c *GitCommand) UnstageAll() error { - return c.OSCommand.RunCommand("git reset") -} - -// UnStageFile unstages a file -func (c *GitCommand) UnStageFile(fileName string, tracked bool) error { - command := "git rm --cached %s" - if tracked { - command = "git reset HEAD %s" - } - - // renamed files look like "file1 -> file2" - fileNames := strings.Split(fileName, " -> ") - for _, name := range fileNames { - if err := c.OSCommand.RunCommand(command, c.OSCommand.Quote(name)); err != nil { - return err - } - } - return nil -} - -// IsInMergeState states whether we are still mid-merge -func (c *GitCommand) IsInMergeState() (bool, error) { - return c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "MERGE_HEAD")) -} - -// RebaseMode returns "" for non-rebase mode, "normal" for normal rebase -// and "interactive" for interactive rebase -func (c *GitCommand) RebaseMode() (string, error) { - exists, err := c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "rebase-apply")) - if err != nil { - return "", err - } - if exists { - return "normal", nil - } - exists, err = c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "rebase-merge")) - if exists { - return "interactive", err - } else { - return "", err - } -} - -func (c *GitCommand) BeforeAndAfterFileForRename(file *models.File) (*models.File, *models.File, error) { - - if !file.IsRename() { - return nil, nil, errors.New("Expected renamed file") - } - - // we've got a file that represents a rename from one file to another. Unfortunately - // our File abstraction fails to consider this case, so here we will refetch - // all files, passing the --no-renames flag and then recursively call the function - // again for the before file and after file. At some point we should fix the abstraction itself - - split := strings.Split(file.Name, " -> ") - filesWithoutRenames := c.GetStatusFiles(GetStatusFileOptions{NoRenames: true}) - var beforeFile *models.File - var afterFile *models.File - for _, f := range filesWithoutRenames { - if f.Name == split[0] { - beforeFile = f - } - if f.Name == split[1] { - afterFile = f - } - } - - if beforeFile == nil || afterFile == nil { - return nil, nil, errors.New("Could not find deleted file or new file for file rename") - } - - if beforeFile.IsRename() || afterFile.IsRename() { - // probably won't happen but we want to ensure we don't get an infinite loop - return nil, nil, errors.New("Nested rename found") - } - - return beforeFile, afterFile, nil -} - -// DiscardAllFileChanges directly -func (c *GitCommand) DiscardAllFileChanges(file *models.File) error { - if file.IsRename() { - beforeFile, afterFile, err := c.BeforeAndAfterFileForRename(file) - if err != nil { - return err - } - - if err := c.DiscardAllFileChanges(beforeFile); err != nil { - return err - } - - if err := c.DiscardAllFileChanges(afterFile); err != nil { - return err - } - - return nil - } - - // if the file isn't tracked, we assume you want to delete it - quotedFileName := c.OSCommand.Quote(file.Name) - if file.HasStagedChanges || file.HasMergeConflicts { - if err := c.OSCommand.RunCommand("git reset -- %s", quotedFileName); err != nil { - return err - } - } - - if !file.Tracked { - return c.removeFile(file.Name) - } - return c.DiscardUnstagedFileChanges(file) -} - -// DiscardUnstagedFileChanges directly -func (c *GitCommand) DiscardUnstagedFileChanges(file *models.File) error { - quotedFileName := c.OSCommand.Quote(file.Name) - return c.OSCommand.RunCommand("git checkout -- %s", quotedFileName) -} - -// Checkout checks out a branch (or commit), with --force if you set the force arg to true -type CheckoutOptions struct { - Force bool - EnvVars []string -} - -func (c *GitCommand) Checkout(branch string, options CheckoutOptions) error { - forceArg := "" - if options.Force { - forceArg = "--force " - } - return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git checkout %s %s", forceArg, branch), oscommands.RunCommandOptions{EnvVars: options.EnvVars}) -} - -// PrepareCommitAmendSubProcess prepares a subprocess for `git commit --amend --allow-empty` -func (c *GitCommand) PrepareCommitAmendSubProcess() *exec.Cmd { - return c.OSCommand.PrepareSubProcess("git", "commit", "--amend", "--allow-empty") -} - -// GetBranchGraph gets the color-formatted graph of the log for the given branch -// Currently it limits the result to 100 commits, but when we get async stuff -// working we can do lazy loading -func (c *GitCommand) GetBranchGraph(branchName string) (string, error) { - cmdStr := c.GetBranchGraphCmdStr(branchName) - return c.OSCommand.RunCommandWithOutput(cmdStr) -} - -func (c *GitCommand) GetUpstreamForBranch(branchName string) (string, error) { - output, err := c.OSCommand.RunCommandWithOutput("git rev-parse --abbrev-ref --symbolic-full-name %s@{u}", branchName) - return strings.TrimSpace(output), err -} - -// Ignore adds a file to the gitignore for the repo -func (c *GitCommand) Ignore(filename string) error { - return c.OSCommand.AppendLineToFile(".gitignore", filename) -} - -func (c *GitCommand) ShowCmdStr(sha string, filterPath string) string { - filterPathArg := "" - if filterPath != "" { - filterPathArg = fmt.Sprintf(" -- %s", c.OSCommand.Quote(filterPath)) - } - return fmt.Sprintf("git show --submodule --color=%s --no-renames --stat -p %s %s", c.colorArg(), sha, filterPathArg) -} - -func (c *GitCommand) GetBranchGraphCmdStr(branchName string) string { - branchLogCmdTemplate := c.Config.GetUserConfig().GetString("git.branchLogCmd") - templateValues := map[string]string{ - "branchName": branchName, - } - return utils.ResolvePlaceholderString(branchLogCmdTemplate, templateValues) -} - -// GetRemoteURL returns current repo remote url -func (c *GitCommand) GetRemoteURL() string { - url, _ := c.OSCommand.RunCommandWithOutput("git config --get remote.origin.url") - return utils.TrimTrailingNewline(url) -} - -// CheckRemoteBranchExists Returns remote branch -func (c *GitCommand) CheckRemoteBranchExists(branch *models.Branch) bool { - _, err := c.OSCommand.RunCommandWithOutput( - "git show-ref --verify -- refs/remotes/origin/%s", - branch.Name, - ) - - return err == nil -} - -// WorktreeFileDiff returns the diff of a file -func (c *GitCommand) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { - // for now we assume an error means the file was deleted - s, _ := c.OSCommand.RunCommandWithOutput(c.WorktreeFileDiffCmdStr(file, plain, cached)) - return s -} - -func (c *GitCommand) WorktreeFileDiffCmdStr(file *models.File, plain bool, cached bool) string { - cachedArg := "" - trackedArg := "--" - colorArg := c.colorArg() - split := strings.Split(file.Name, " -> ") // in case of a renamed file we get the new filename - fileName := c.OSCommand.Quote(split[len(split)-1]) - if cached { - cachedArg = "--cached" - } - if !file.Tracked && !file.HasStagedChanges && !cached { - trackedArg = "--no-index /dev/null" - } - if plain { - colorArg = "never" - } - - return fmt.Sprintf("git diff --submodule --no-ext-diff --color=%s %s %s %s", colorArg, cachedArg, trackedArg, fileName) -} - -func (c *GitCommand) ApplyPatch(patch string, flags ...string) error { - filepath := filepath.Join(c.Config.GetUserConfigDir(), utils.GetCurrentRepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch") - c.Log.Infof("saving temporary patch to %s", filepath) - if err := c.OSCommand.CreateFileWithContent(filepath, patch); err != nil { - return err - } - - flagStr := "" - for _, flag := range flags { - flagStr += " --" + flag - } - - return c.OSCommand.RunCommand("git apply %s %s", flagStr, c.OSCommand.Quote(filepath)) -} - -func (c *GitCommand) FastForward(branchName string, remoteName string, remoteBranchName string, promptUserForCredential func(string) string) error { - command := fmt.Sprintf("git fetch %s %s:%s", remoteName, remoteBranchName, branchName) - return c.OSCommand.DetectUnamePass(command, promptUserForCredential) -} - -func (c *GitCommand) RunSkipEditorCommand(command string) error { - cmd := c.OSCommand.ExecutableFromString(command) - lazyGitPath := c.OSCommand.GetLazygitPath() - cmd.Env = append( - cmd.Env, - "LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY", - "GIT_EDITOR="+lazyGitPath, - "EDITOR="+lazyGitPath, - "VISUAL="+lazyGitPath, - ) - return c.OSCommand.RunExecutable(cmd) -} - -// GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue" -// By default we skip the editor in the case where a commit will be made -func (c *GitCommand) GenericMerge(commandType string, command string) error { - err := c.RunSkipEditorCommand( - fmt.Sprintf( - "git %s --%s", - commandType, - command, - ), - ) - if err != nil { - if !strings.Contains(err.Error(), "no rebase in progress") { - return err - } - c.Log.Warn(err) - } - - // sometimes we need to do a sequence of things in a rebase but the user needs to - // fix merge conflicts along the way. When this happens we queue up the next step - // so that after the next successful rebase continue we can continue from where we left off - if commandType == "rebase" && command == "continue" && c.onSuccessfulContinue != nil { - f := c.onSuccessfulContinue - c.onSuccessfulContinue = nil - return f() - } - if command == "abort" { - c.onSuccessfulContinue = nil - } - return nil -} - -func (c *GitCommand) RewordCommit(commits []*models.Commit, index int) (*exec.Cmd, error) { - todo, sha, err := c.GenerateGenericRebaseTodo(commits, index, "reword") - if err != nil { - return nil, err - } - - return c.PrepareInteractiveRebaseCommand(sha, todo, false) -} - -func (c *GitCommand) MoveCommitDown(commits []*models.Commit, index int) error { - // we must ensure that we have at least two commits after the selected one - if len(commits) <= index+2 { - // assuming they aren't picking the bottom commit - return errors.New(c.Tr.SLocalize("NoRoom")) - } - - todo := "" - orderedCommits := append(commits[0:index], commits[index+1], commits[index]) - for _, commit := range orderedCommits { - todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo - } - - cmd, err := c.PrepareInteractiveRebaseCommand(commits[index+2].Sha, todo, true) - if err != nil { - return err - } - - return c.OSCommand.RunPreparedCommand(cmd) -} - -func (c *GitCommand) InteractiveRebase(commits []*models.Commit, index int, action string) error { - todo, sha, err := c.GenerateGenericRebaseTodo(commits, index, action) - if err != nil { - return err - } - - cmd, err := c.PrepareInteractiveRebaseCommand(sha, todo, true) - if err != nil { - return err - } - - return c.OSCommand.RunPreparedCommand(cmd) -} - -// PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase -// we tell git to run lazygit to edit the todo list, and we pass the client -// lazygit a todo string to write to the todo file -func (c *GitCommand) PrepareInteractiveRebaseCommand(baseSha string, todo string, overrideEditor bool) (*exec.Cmd, error) { - ex := c.OSCommand.GetLazygitPath() - - debug := "FALSE" - if c.OSCommand.Config.GetDebug() { - debug = "TRUE" - } - - cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty %s", baseSha) - c.Log.WithField("command", cmdStr).Info("RunCommand") - splitCmd := str.ToArgv(cmdStr) - - cmd := c.OSCommand.Command(splitCmd[0], splitCmd[1:]...) - - gitSequenceEditor := ex - if todo == "" { - gitSequenceEditor = "true" - } - - cmd.Env = os.Environ() - cmd.Env = append( - cmd.Env, - "LAZYGIT_CLIENT_COMMAND=INTERACTIVE_REBASE", - "LAZYGIT_REBASE_TODO="+todo, - "DEBUG="+debug, - "LANG=en_US.UTF-8", // Force using EN as language - "LC_ALL=en_US.UTF-8", // Force using EN as language - "GIT_SEQUENCE_EDITOR="+gitSequenceEditor, - ) - - if overrideEditor { - cmd.Env = append(cmd.Env, "GIT_EDITOR="+ex) - } - - return cmd, nil -} - -func (c *GitCommand) HardReset(baseSha string) error { - return c.OSCommand.RunCommand("git reset --hard " + baseSha) -} - -func (c *GitCommand) SoftReset(baseSha string) error { - return c.OSCommand.RunCommand("git reset --soft " + baseSha) -} - -func (c *GitCommand) GenerateGenericRebaseTodo(commits []*models.Commit, actionIndex int, action string) (string, string, error) { - baseIndex := actionIndex + 1 - - if len(commits) <= baseIndex { - return "", "", errors.New(c.Tr.SLocalize("CannotRebaseOntoFirstCommit")) - } - - if action == "squash" || action == "fixup" { - baseIndex++ - - if len(commits) <= baseIndex { - return "", "", errors.New(c.Tr.SLocalize("CannotSquashOntoSecondCommit")) - } - } - - todo := "" - for i, commit := range commits[0:baseIndex] { - var commitAction string - if i == actionIndex { - commitAction = action - } else if commit.IsMerge { - // your typical interactive rebase will actually drop merge commits by default. Damn git CLI, you scary! - // doing this means we don't need to worry about rebasing over merges which always causes problems. - // you typically shouldn't be doing rebases that pass over merge commits anyway. - commitAction = "drop" - } else { - commitAction = "pick" - } - todo = commitAction + " " + commit.Sha + " " + commit.Name + "\n" + todo - } - - return todo, commits[baseIndex].Sha, nil -} - -// AmendTo amends the given commit with whatever files are staged -func (c *GitCommand) AmendTo(sha string) error { - if err := c.CreateFixupCommit(sha); err != nil { - return err - } - - return c.SquashAllAboveFixupCommits(sha) -} - -// EditRebaseTodo sets the action at a given index in the git-rebase-todo file -func (c *GitCommand) EditRebaseTodo(index int, action string) error { - fileName := filepath.Join(c.DotGitDir, "rebase-merge/git-rebase-todo") - bytes, err := ioutil.ReadFile(fileName) - if err != nil { - return err - } - - content := strings.Split(string(bytes), "\n") - commitCount := c.getTodoCommitCount(content) - - // we have the most recent commit at the bottom whereas the todo file has - // it at the bottom, so we need to subtract our index from the commit count - contentIndex := commitCount - 1 - index - splitLine := strings.Split(content[contentIndex], " ") - content[contentIndex] = action + " " + strings.Join(splitLine[1:], " ") - result := strings.Join(content, "\n") - - return ioutil.WriteFile(fileName, []byte(result), 0644) -} - -func (c *GitCommand) getTodoCommitCount(content []string) int { - // count lines that are not blank and are not comments - commitCount := 0 - for _, line := range content { - if line != "" && !strings.HasPrefix(line, "#") { - commitCount++ - } - } - return commitCount -} - -// MoveTodoDown moves a rebase todo item down by one position -func (c *GitCommand) MoveTodoDown(index int) error { - fileName := filepath.Join(c.DotGitDir, "rebase-merge/git-rebase-todo") - bytes, err := ioutil.ReadFile(fileName) - if err != nil { - return err - } - - content := strings.Split(string(bytes), "\n") - commitCount := c.getTodoCommitCount(content) - contentIndex := commitCount - 1 - index - - rearrangedContent := append(content[0:contentIndex-1], content[contentIndex], content[contentIndex-1]) - rearrangedContent = append(rearrangedContent, content[contentIndex+1:]...) - result := strings.Join(rearrangedContent, "\n") - - return ioutil.WriteFile(fileName, []byte(result), 0644) -} - -// Revert reverts the selected commit by sha -func (c *GitCommand) Revert(sha string) error { - return c.OSCommand.RunCommand("git revert %s", sha) -} - -// CherryPickCommits begins an interactive rebase with the given shas being cherry picked onto HEAD -func (c *GitCommand) CherryPickCommits(commits []*models.Commit) error { - todo := "" - for _, commit := range commits { - todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo - } - - cmd, err := c.PrepareInteractiveRebaseCommand("HEAD", todo, false) - if err != nil { - return err - } - - return c.OSCommand.RunPreparedCommand(cmd) -} - -// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc -// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. -func (c *GitCommand) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) { - cmdStr := c.ShowFileDiffCmdStr(from, to, reverse, fileName, plain) - return c.OSCommand.RunCommandWithOutput(cmdStr) -} - -func (c *GitCommand) ShowFileDiffCmdStr(from string, to string, reverse bool, fileName string, plain bool) string { - colorArg := c.colorArg() - if plain { - colorArg = "never" - } - - reverseFlag := "" - if reverse { - reverseFlag = " -R " - } - - return fmt.Sprintf("git diff --submodule --no-ext-diff --no-renames --color=%s %s %s %s -- %s", colorArg, from, to, reverseFlag, fileName) -} - -// CheckoutFile checks out the file for the given commit -func (c *GitCommand) CheckoutFile(commitSha, fileName string) error { - return c.OSCommand.RunCommand("git checkout %s %s", commitSha, fileName) -} - -// DiscardOldFileChanges discards changes to a file from an old commit -func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex int, fileName string) error { - if err := c.BeginInteractiveRebaseForCommit(commits, commitIndex); err != nil { - return err - } - - // check if file exists in previous commit (this command returns an error if the file doesn't exist) - if err := c.OSCommand.RunCommand("git cat-file -e HEAD^:%s", fileName); err != nil { - if err := c.OSCommand.Remove(fileName); err != nil { - return err - } - if err := c.StageFile(fileName); err != nil { - return err - } - } else if err := c.CheckoutFile("HEAD^", fileName); err != nil { - return err - } - - // amend the commit - cmd, err := c.AmendHead() - if cmd != nil { - return errors.New("received unexpected pointer to cmd") - } - if err != nil { - return err - } - - // continue - return c.GenericMerge("rebase", "continue") -} - -// DiscardAnyUnstagedFileChanges discards any unstages file changes via `git checkout -- .` -func (c *GitCommand) DiscardAnyUnstagedFileChanges() error { - return c.OSCommand.RunCommand("git checkout -- .") -} - -// RemoveTrackedFiles will delete the given file(s) even if they are currently tracked -func (c *GitCommand) RemoveTrackedFiles(name string) error { - return c.OSCommand.RunCommand("git rm -r --cached %s", name) -} - -// RemoveUntrackedFiles runs `git clean -fd` -func (c *GitCommand) RemoveUntrackedFiles() error { - return c.OSCommand.RunCommand("git clean -fd") -} - -// ResetHardHead runs `git reset --hard` -func (c *GitCommand) ResetHard(ref string) error { - return c.OSCommand.RunCommand("git reset --hard " + ref) -} - -// ResetSoft runs `git reset --soft HEAD` -func (c *GitCommand) ResetSoft(ref string) error { - return c.OSCommand.RunCommand("git reset --soft " + ref) -} - -// CreateFixupCommit creates a commit that fixes up a previous commit -func (c *GitCommand) CreateFixupCommit(sha string) error { - return c.OSCommand.RunCommand("git commit --fixup=%s", sha) -} - -// SquashAllAboveFixupCommits squashes all fixup! commits above the given one -func (c *GitCommand) SquashAllAboveFixupCommits(sha string) error { - return c.RunSkipEditorCommand( - fmt.Sprintf( - "git rebase --interactive --autostash --autosquash %s^", - sha, - ), - ) -} - -// StashSaveStagedChanges stashes only the currently staged changes. This takes a few steps -// shoutouts to Joe on https://stackoverflow.com/questions/14759748/stashing-only-staged-changes-in-git-is-it-possible -func (c *GitCommand) StashSaveStagedChanges(message string) error { - - if err := c.OSCommand.RunCommand("git stash --keep-index"); err != nil { - return err - } - - if err := c.StashSave(message); err != nil { - return err - } - - if err := c.OSCommand.RunCommand("git stash apply stash@{1}"); err != nil { - return err - } - - if err := c.OSCommand.PipeCommands("git stash show -p", "git apply -R"); err != nil { - return err - } - - if err := c.OSCommand.RunCommand("git stash drop stash@{1}"); err != nil { - return err - } - - // if you had staged an untracked file, that will now appear as 'AD' in git status - // meaning it's deleted in your working tree but added in your index. Given that it's - // now safely stashed, we need to remove it. - files := c.GetStatusFiles(GetStatusFileOptions{}) - for _, file := range files { - if file.ShortStatus == "AD" { - if err := c.UnStageFile(file.Name, false); err != nil { - return err - } - } - } - - return nil -} - -// BeginInteractiveRebaseForCommit starts an interactive rebase to edit the current -// commit and pick all others. After this you'll want to call `c.GenericMerge("rebase", "continue")` -func (c *GitCommand) BeginInteractiveRebaseForCommit(commits []*models.Commit, commitIndex int) error { - if len(commits)-1 < commitIndex { - return errors.New("index outside of range of commits") - } - - // we can make this GPG thing possible it just means we need to do this in two parts: - // one where we handle the possibility of a credential request, and the other - // where we continue the rebase - if c.usingGpg() { - return errors.New(c.Tr.SLocalize("DisabledForGPG")) - } - - todo, sha, err := c.GenerateGenericRebaseTodo(commits, commitIndex, "edit") - if err != nil { - return err - } - - cmd, err := c.PrepareInteractiveRebaseCommand(sha, todo, true) - if err != nil { - return err - } - - if err := c.OSCommand.RunPreparedCommand(cmd); err != nil { - return err - } - - return nil -} - -func (c *GitCommand) SetUpstreamBranch(upstream string) error { - return c.OSCommand.RunCommand("git branch -u %s", upstream) -} - -func (c *GitCommand) AddRemote(name string, url string) error { - return c.OSCommand.RunCommand("git remote add %s %s", name, url) -} - -func (c *GitCommand) RemoveRemote(name string) error { - return c.OSCommand.RunCommand("git remote remove %s", name) -} - -func (c *GitCommand) IsHeadDetached() bool { - err := c.OSCommand.RunCommand("git symbolic-ref -q HEAD") - return err != nil -} - -func (c *GitCommand) DeleteRemoteBranch(remoteName string, branchName string) error { - return c.OSCommand.RunCommand("git push %s --delete %s", remoteName, branchName) -} - -func (c *GitCommand) SetBranchUpstream(remoteName string, remoteBranchName string, branchName string) error { - return c.OSCommand.RunCommand("git branch --set-upstream-to=%s/%s %s", remoteName, remoteBranchName, branchName) -} - -func (c *GitCommand) RenameRemote(oldRemoteName string, newRemoteName string) error { - return c.OSCommand.RunCommand("git remote rename %s %s", oldRemoteName, newRemoteName) -} - -func (c *GitCommand) UpdateRemoteUrl(remoteName string, updatedUrl string) error { - return c.OSCommand.RunCommand("git remote set-url %s %s", remoteName, updatedUrl) -} - -func (c *GitCommand) CreateLightweightTag(tagName string, commitSha string) error { - return c.OSCommand.RunCommand("git tag %s %s", tagName, commitSha) -} - -func (c *GitCommand) DeleteTag(tagName string) error { - return c.OSCommand.RunCommand("git tag -d %s", tagName) -} - -func (c *GitCommand) PushTag(remoteName string, tagName string) error { - return c.OSCommand.RunCommand("git push %s %s", remoteName, tagName) -} - -func (c *GitCommand) FetchRemote(remoteName string) error { - return c.OSCommand.RunCommand("git fetch %s", remoteName) -} - -func (c *GitCommand) ConfiguredPager() string { - if os.Getenv("GIT_PAGER") != "" { - return os.Getenv("GIT_PAGER") - } - if os.Getenv("PAGER") != "" { - return os.Getenv("PAGER") - } - output, err := c.OSCommand.RunCommandWithOutput("git config --get-all core.pager") - if err != nil { - return "" - } - trimmedOutput := strings.TrimSpace(output) - return strings.Split(trimmedOutput, "\n")[0] -} - -func (c *GitCommand) GetPager(width int) string { - useConfig := c.Config.GetUserConfig().GetBool("git.paging.useConfig") - if useConfig { - pager := c.ConfiguredPager() - return strings.Split(pager, "| less")[0] - } - - templateValues := map[string]string{ - "columnWidth": strconv.Itoa(width/2 - 6), - } - - pagerTemplate := c.Config.GetUserConfig().GetString("git.paging.pager") - return utils.ResolvePlaceholderString(pagerTemplate, templateValues) -} - -func (c *GitCommand) colorArg() string { - return c.Config.GetUserConfig().GetString("git.paging.colorArg") -} - -func (c *GitCommand) RenameBranch(oldName string, newName string) error { - return c.OSCommand.RunCommand("git branch --move %s %s", oldName, newName) -} - -func (c *GitCommand) WorkingTreeState() string { - rebaseMode, _ := c.RebaseMode() - if rebaseMode != "" { - return "rebasing" - } - merging, _ := c.IsInMergeState() - if merging { - return "merging" - } - return "normal" -} - -func (c *GitCommand) IsBareRepo() bool { - // note: could use `git rev-parse --is-bare-repository` if we wanna drop go-git - _, err := c.Repo.Worktree() - return err == gogit.ErrIsBareRepository -} diff --git a/pkg/commands/git_test.go b/pkg/commands/git_test.go index 812f24cc3..3bad85688 100644 --- a/pkg/commands/git_test.go +++ b/pkg/commands/git_test.go @@ -2012,7 +2012,7 @@ func TestGitCommandSkipEditorCommand(t *testing.T) { ) }) - _ = cmd.RunSkipEditorCommand("true") + _ = cmd.runSkipEditorCommand("true") } func TestFindDotGitDir(t *testing.T) { diff --git a/pkg/commands/branch_list_builder.go b/pkg/commands/loading_branches.go similarity index 100% rename from pkg/commands/branch_list_builder.go rename to pkg/commands/loading_branches.go diff --git a/pkg/commands/commit_list_builder.go b/pkg/commands/loading_commits.go similarity index 100% rename from pkg/commands/commit_list_builder.go rename to pkg/commands/loading_commits.go diff --git a/pkg/commands/commit_list_builder_test.go b/pkg/commands/loading_commits_test.go similarity index 100% rename from pkg/commands/commit_list_builder_test.go rename to pkg/commands/loading_commits_test.go diff --git a/pkg/commands/loading_files.go b/pkg/commands/loading_files.go index d68dcd726..8455ed0d5 100644 --- a/pkg/commands/loading_files.go +++ b/pkg/commands/loading_files.go @@ -8,6 +8,11 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" ) +// GetStatusFiles git status files +type GetStatusFileOptions struct { + NoRenames bool +} + func (c *GitCommand) GetStatusFiles(opts GetStatusFileOptions) []*models.File { // check if config wants us ignoring untracked files untrackedFilesSetting := c.GetConfigValue("status.showUntrackedFiles") @@ -82,7 +87,7 @@ func (c *GitCommand) MergeStatusFiles(oldFiles, newFiles []*models.File, selecte result := []*models.File{} for _, oldFile := range oldFiles { for newIndex, newFile := range newFiles { - if includesInt(appendedIndexes, newIndex) { + if utils.IncludesInt(appendedIndexes, newIndex) { continue } // if we just staged B and in doing so created 'A -> B' and we are currently have oldFile: A and newFile: 'A -> B', we want to wait until we come across B so the our cursor isn't jumping anywhere @@ -97,7 +102,7 @@ func (c *GitCommand) MergeStatusFiles(oldFiles, newFiles []*models.File, selecte // append any new files to the end for index, newFile := range newFiles { - if !includesInt(appendedIndexes, index) { + if !utils.IncludesInt(appendedIndexes, index) { result = append(result, newFile) } } diff --git a/pkg/commands/patch_rebases.go b/pkg/commands/patch_rebases.go index 6d032b446..a926e491e 100644 --- a/pkg/commands/patch_rebases.go +++ b/pkg/commands/patch_rebases.go @@ -16,7 +16,7 @@ func (c *GitCommand) DeletePatchesFromCommit(commits []*models.Commit, commitInd // apply each patch in reverse if err := p.ApplyPatches(true); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -33,7 +33,7 @@ func (c *GitCommand) DeletePatchesFromCommit(commits []*models.Commit, commitInd } // continue - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceCommitIdx int, destinationCommitIdx int, p *patch.PatchManager) error { @@ -44,7 +44,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC // apply each patch forward if err := p.ApplyPatches(false); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -61,7 +61,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC } // continue - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } if len(commits)-1 < sourceCommitIdx { @@ -96,7 +96,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC // apply each patch in reverse if err := p.ApplyPatches(true); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -115,7 +115,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC // now we should be up to the destination, so let's apply forward these patches to that. // ideally we would ensure we're on the right commit but I'm not sure if that check is necessary if err := p.ApplyPatches(false); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -131,10 +131,10 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC return nil } - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int, p *patch.PatchManager, stash bool) error { @@ -150,7 +150,7 @@ func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int, if err := p.ApplyPatches(true); err != nil { if c.WorkingTreeState() == "rebasing" { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } } @@ -170,7 +170,7 @@ func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int, // add patches to index if err := p.ApplyPatches(false); err != nil { if c.WorkingTreeState() == "rebasing" { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } } @@ -187,7 +187,7 @@ func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int, return nil } - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx int, p *patch.PatchManager) error { @@ -196,7 +196,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx } if err := p.ApplyPatches(true); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -209,7 +209,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx // add patches to index if err := p.ApplyPatches(false); err != nil { - if err := c.GenericMerge("rebase", "abort"); err != nil { + if err := c.GenericMergeOrRebaseAction("rebase", "abort"); err != nil { return err } return err @@ -227,5 +227,5 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx } c.PatchManager.Reset() - return c.GenericMerge("rebase", "continue") + return c.GenericMergeOrRebaseAction("rebase", "continue") } diff --git a/pkg/commands/rebasing.go b/pkg/commands/rebasing.go new file mode 100644 index 000000000..30e87c5dc --- /dev/null +++ b/pkg/commands/rebasing.go @@ -0,0 +1,287 @@ +package commands + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/models" + "github.com/mgutz/str" +) + +func (c *GitCommand) RewordCommit(commits []*models.Commit, index int) (*exec.Cmd, error) { + todo, sha, err := c.GenerateGenericRebaseTodo(commits, index, "reword") + if err != nil { + return nil, err + } + + return c.PrepareInteractiveRebaseCommand(sha, todo, false) +} + +func (c *GitCommand) MoveCommitDown(commits []*models.Commit, index int) error { + // we must ensure that we have at least two commits after the selected one + if len(commits) <= index+2 { + // assuming they aren't picking the bottom commit + return errors.New(c.Tr.SLocalize("NoRoom")) + } + + todo := "" + orderedCommits := append(commits[0:index], commits[index+1], commits[index]) + for _, commit := range orderedCommits { + todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo + } + + cmd, err := c.PrepareInteractiveRebaseCommand(commits[index+2].Sha, todo, true) + if err != nil { + return err + } + + return c.OSCommand.RunPreparedCommand(cmd) +} + +func (c *GitCommand) InteractiveRebase(commits []*models.Commit, index int, action string) error { + todo, sha, err := c.GenerateGenericRebaseTodo(commits, index, action) + if err != nil { + return err + } + + cmd, err := c.PrepareInteractiveRebaseCommand(sha, todo, true) + if err != nil { + return err + } + + return c.OSCommand.RunPreparedCommand(cmd) +} + +// PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase +// we tell git to run lazygit to edit the todo list, and we pass the client +// lazygit a todo string to write to the todo file +func (c *GitCommand) PrepareInteractiveRebaseCommand(baseSha string, todo string, overrideEditor bool) (*exec.Cmd, error) { + ex := c.OSCommand.GetLazygitPath() + + debug := "FALSE" + if c.OSCommand.Config.GetDebug() { + debug = "TRUE" + } + + cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty %s", baseSha) + c.Log.WithField("command", cmdStr).Info("RunCommand") + splitCmd := str.ToArgv(cmdStr) + + cmd := c.OSCommand.Command(splitCmd[0], splitCmd[1:]...) + + gitSequenceEditor := ex + if todo == "" { + gitSequenceEditor = "true" + } + + cmd.Env = os.Environ() + cmd.Env = append( + cmd.Env, + "LAZYGIT_CLIENT_COMMAND=INTERACTIVE_REBASE", + "LAZYGIT_REBASE_TODO="+todo, + "DEBUG="+debug, + "LANG=en_US.UTF-8", // Force using EN as language + "LC_ALL=en_US.UTF-8", // Force using EN as language + "GIT_SEQUENCE_EDITOR="+gitSequenceEditor, + ) + + if overrideEditor { + cmd.Env = append(cmd.Env, "GIT_EDITOR="+ex) + } + + return cmd, nil +} + +func (c *GitCommand) GenerateGenericRebaseTodo(commits []*models.Commit, actionIndex int, action string) (string, string, error) { + baseIndex := actionIndex + 1 + + if len(commits) <= baseIndex { + return "", "", errors.New(c.Tr.SLocalize("CannotRebaseOntoFirstCommit")) + } + + if action == "squash" || action == "fixup" { + baseIndex++ + + if len(commits) <= baseIndex { + return "", "", errors.New(c.Tr.SLocalize("CannotSquashOntoSecondCommit")) + } + } + + todo := "" + for i, commit := range commits[0:baseIndex] { + var commitAction string + if i == actionIndex { + commitAction = action + } else if commit.IsMerge { + // your typical interactive rebase will actually drop merge commits by default. Damn git CLI, you scary! + // doing this means we don't need to worry about rebasing over merges which always causes problems. + // you typically shouldn't be doing rebases that pass over merge commits anyway. + commitAction = "drop" + } else { + commitAction = "pick" + } + todo = commitAction + " " + commit.Sha + " " + commit.Name + "\n" + todo + } + + return todo, commits[baseIndex].Sha, nil +} + +// AmendTo amends the given commit with whatever files are staged +func (c *GitCommand) AmendTo(sha string) error { + if err := c.CreateFixupCommit(sha); err != nil { + return err + } + + return c.SquashAllAboveFixupCommits(sha) +} + +// EditRebaseTodo sets the action at a given index in the git-rebase-todo file +func (c *GitCommand) EditRebaseTodo(index int, action string) error { + fileName := filepath.Join(c.DotGitDir, "rebase-merge/git-rebase-todo") + bytes, err := ioutil.ReadFile(fileName) + if err != nil { + return err + } + + content := strings.Split(string(bytes), "\n") + commitCount := c.getTodoCommitCount(content) + + // we have the most recent commit at the bottom whereas the todo file has + // it at the bottom, so we need to subtract our index from the commit count + contentIndex := commitCount - 1 - index + splitLine := strings.Split(content[contentIndex], " ") + content[contentIndex] = action + " " + strings.Join(splitLine[1:], " ") + result := strings.Join(content, "\n") + + return ioutil.WriteFile(fileName, []byte(result), 0644) +} + +func (c *GitCommand) getTodoCommitCount(content []string) int { + // count lines that are not blank and are not comments + commitCount := 0 + for _, line := range content { + if line != "" && !strings.HasPrefix(line, "#") { + commitCount++ + } + } + return commitCount +} + +// MoveTodoDown moves a rebase todo item down by one position +func (c *GitCommand) MoveTodoDown(index int) error { + fileName := filepath.Join(c.DotGitDir, "rebase-merge/git-rebase-todo") + bytes, err := ioutil.ReadFile(fileName) + if err != nil { + return err + } + + content := strings.Split(string(bytes), "\n") + commitCount := c.getTodoCommitCount(content) + contentIndex := commitCount - 1 - index + + rearrangedContent := append(content[0:contentIndex-1], content[contentIndex], content[contentIndex-1]) + rearrangedContent = append(rearrangedContent, content[contentIndex+1:]...) + result := strings.Join(rearrangedContent, "\n") + + return ioutil.WriteFile(fileName, []byte(result), 0644) +} + +// SquashAllAboveFixupCommits squashes all fixup! commits above the given one +func (c *GitCommand) SquashAllAboveFixupCommits(sha string) error { + return c.runSkipEditorCommand( + fmt.Sprintf( + "git rebase --interactive --autostash --autosquash %s^", + sha, + ), + ) +} + +// BeginInteractiveRebaseForCommit starts an interactive rebase to edit the current +// commit and pick all others. After this you'll want to call `c.GenericMergeOrRebaseAction("rebase", "continue")` +func (c *GitCommand) BeginInteractiveRebaseForCommit(commits []*models.Commit, commitIndex int) error { + if len(commits)-1 < commitIndex { + return errors.New("index outside of range of commits") + } + + // we can make this GPG thing possible it just means we need to do this in two parts: + // one where we handle the possibility of a credential request, and the other + // where we continue the rebase + if c.usingGpg() { + return errors.New(c.Tr.SLocalize("DisabledForGPG")) + } + + todo, sha, err := c.GenerateGenericRebaseTodo(commits, commitIndex, "edit") + if err != nil { + return err + } + + cmd, err := c.PrepareInteractiveRebaseCommand(sha, todo, true) + if err != nil { + return err + } + + if err := c.OSCommand.RunPreparedCommand(cmd); err != nil { + return err + } + + return nil +} + +// RebaseBranch interactive rebases onto a branch +func (c *GitCommand) RebaseBranch(branchName string) error { + cmd, err := c.PrepareInteractiveRebaseCommand(branchName, "", false) + if err != nil { + return err + } + + return c.OSCommand.RunPreparedCommand(cmd) +} + +// GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue" +// By default we skip the editor in the case where a commit will be made +func (c *GitCommand) GenericMergeOrRebaseAction(commandType string, command string) error { + err := c.runSkipEditorCommand( + fmt.Sprintf( + "git %s --%s", + commandType, + command, + ), + ) + if err != nil { + if !strings.Contains(err.Error(), "no rebase in progress") { + return err + } + c.Log.Warn(err) + } + + // sometimes we need to do a sequence of things in a rebase but the user needs to + // fix merge conflicts along the way. When this happens we queue up the next step + // so that after the next successful rebase continue we can continue from where we left off + if commandType == "rebase" && command == "continue" && c.onSuccessfulContinue != nil { + f := c.onSuccessfulContinue + c.onSuccessfulContinue = nil + return f() + } + if command == "abort" { + c.onSuccessfulContinue = nil + } + return nil +} + +func (c *GitCommand) runSkipEditorCommand(command string) error { + cmd := c.OSCommand.ExecutableFromString(command) + lazyGitPath := c.OSCommand.GetLazygitPath() + cmd.Env = append( + cmd.Env, + "LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY", + "GIT_EDITOR="+lazyGitPath, + "EDITOR="+lazyGitPath, + "VISUAL="+lazyGitPath, + ) + return c.OSCommand.RunExecutable(cmd) +} diff --git a/pkg/commands/remotes.go b/pkg/commands/remotes.go new file mode 100644 index 000000000..59793a155 --- /dev/null +++ b/pkg/commands/remotes.go @@ -0,0 +1,40 @@ +package commands + +import ( + "github.com/jesseduffield/lazygit/pkg/models" +) + +func (c *GitCommand) AddRemote(name string, url string) error { + return c.OSCommand.RunCommand("git remote add %s %s", name, url) +} + +func (c *GitCommand) RemoveRemote(name string) error { + return c.OSCommand.RunCommand("git remote remove %s", name) +} + +func (c *GitCommand) RenameRemote(oldRemoteName string, newRemoteName string) error { + return c.OSCommand.RunCommand("git remote rename %s %s", oldRemoteName, newRemoteName) +} + +func (c *GitCommand) UpdateRemoteUrl(remoteName string, updatedUrl string) error { + return c.OSCommand.RunCommand("git remote set-url %s %s", remoteName, updatedUrl) +} + +func (c *GitCommand) DeleteRemoteBranch(remoteName string, branchName string) error { + return c.OSCommand.RunCommand("git push %s --delete %s", remoteName, branchName) +} + +// CheckRemoteBranchExists Returns remote branch +func (c *GitCommand) CheckRemoteBranchExists(branch *models.Branch) bool { + _, err := c.OSCommand.RunCommandWithOutput( + "git show-ref --verify -- refs/remotes/origin/%s", + branch.Name, + ) + + return err == nil +} + +// GetRemoteURL returns current repo remote url +func (c *GitCommand) GetRemoteURL() string { + return c.GetConfigValue("remote.origin.url") +} diff --git a/pkg/commands/stash_entries.go b/pkg/commands/stash_entries.go new file mode 100644 index 000000000..1bc957130 --- /dev/null +++ b/pkg/commands/stash_entries.go @@ -0,0 +1,58 @@ +package commands + +import "fmt" + +// StashDo modify stash +func (c *GitCommand) StashDo(index int, method string) error { + return c.OSCommand.RunCommand("git stash %s stash@{%d}", method, index) +} + +// StashSave save stash +// TODO: before calling this, check if there is anything to save +func (c *GitCommand) StashSave(message string) error { + return c.OSCommand.RunCommand("git stash save %s", c.OSCommand.Quote(message)) +} + +// GetStashEntryDiff stash diff +func (c *GitCommand) ShowStashEntryCmdStr(index int) string { + return fmt.Sprintf("git stash show -p --stat --color=%s stash@{%d}", c.colorArg(), index) +} + +// StashSaveStagedChanges stashes only the currently staged changes. This takes a few steps +// shoutouts to Joe on https://stackoverflow.com/questions/14759748/stashing-only-staged-changes-in-git-is-it-possible +func (c *GitCommand) StashSaveStagedChanges(message string) error { + + if err := c.OSCommand.RunCommand("git stash --keep-index"); err != nil { + return err + } + + if err := c.StashSave(message); err != nil { + return err + } + + if err := c.OSCommand.RunCommand("git stash apply stash@{1}"); err != nil { + return err + } + + if err := c.OSCommand.PipeCommands("git stash show -p", "git apply -R"); err != nil { + return err + } + + if err := c.OSCommand.RunCommand("git stash drop stash@{1}"); err != nil { + return err + } + + // if you had staged an untracked file, that will now appear as 'AD' in git status + // meaning it's deleted in your working tree but added in your index. Given that it's + // now safely stashed, we need to remove it. + files := c.GetStatusFiles(GetStatusFileOptions{}) + for _, file := range files { + if file.ShortStatus == "AD" { + if err := c.UnStageFile(file.Name, false); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/commands/status.go b/pkg/commands/status.go new file mode 100644 index 000000000..83d16759c --- /dev/null +++ b/pkg/commands/status.go @@ -0,0 +1,48 @@ +package commands + +import ( + "path/filepath" + + gogit "github.com/go-git/go-git/v5" +) + +// RebaseMode returns "" for non-rebase mode, "normal" for normal rebase +// and "interactive" for interactive rebase +func (c *GitCommand) RebaseMode() (string, error) { + exists, err := c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "rebase-apply")) + if err != nil { + return "", err + } + if exists { + return "normal", nil + } + exists, err = c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "rebase-merge")) + if exists { + return "interactive", err + } else { + return "", err + } +} + +func (c *GitCommand) WorkingTreeState() string { + rebaseMode, _ := c.RebaseMode() + if rebaseMode != "" { + return "rebasing" + } + merging, _ := c.IsInMergeState() + if merging { + return "merging" + } + return "normal" +} + +// IsInMergeState states whether we are still mid-merge +func (c *GitCommand) IsInMergeState() (bool, error) { + return c.OSCommand.FileExists(filepath.Join(c.DotGitDir, "MERGE_HEAD")) +} + +func (c *GitCommand) IsBareRepo() bool { + // note: could use `git rev-parse --is-bare-repository` if we wanna drop go-git + _, err := c.Repo.Worktree() + return err == gogit.ErrIsBareRepository +} diff --git a/pkg/commands/sync.go b/pkg/commands/sync.go new file mode 100644 index 000000000..1321af2d3 --- /dev/null +++ b/pkg/commands/sync.go @@ -0,0 +1,74 @@ +package commands + +import ( + "fmt" + "strings" +) + +// usingGpg tells us whether the user has gpg enabled so that we can know +// whether we need to run a subprocess to allow them to enter their password +func (c *GitCommand) usingGpg() bool { + overrideGpg := c.Config.GetUserConfig().GetBool("git.overrideGpg") + if overrideGpg { + return false + } + + gpgsign, _ := c.getLocalGitConfig("commit.gpgsign") + if gpgsign == "" { + gpgsign, _ = c.getGlobalGitConfig("commit.gpgsign") + } + value := strings.ToLower(gpgsign) + + return value == "true" || value == "1" || value == "yes" || value == "on" +} + +// Push pushes to a branch +func (c *GitCommand) Push(branchName string, force bool, upstream string, args string, promptUserForCredential func(string) string) error { + forceFlag := "" + if force { + forceFlag = "--force-with-lease" + } + + setUpstreamArg := "" + if upstream != "" { + setUpstreamArg = "--set-upstream " + upstream + } + + cmd := fmt.Sprintf("git push --follow-tags %s %s %s", forceFlag, setUpstreamArg, args) + return c.OSCommand.DetectUnamePass(cmd, promptUserForCredential) +} + +type FetchOptions struct { + PromptUserForCredential func(string) string + RemoteName string + BranchName string +} + +// Fetch fetch git repo +func (c *GitCommand) Fetch(opts FetchOptions) error { + command := "git fetch" + + if opts.RemoteName != "" { + command = fmt.Sprintf("%s %s", command, opts.RemoteName) + } + if opts.BranchName != "" { + command = fmt.Sprintf("%s %s", command, opts.BranchName) + } + + return c.OSCommand.DetectUnamePass(command, func(question string) string { + if opts.PromptUserForCredential != nil { + return opts.PromptUserForCredential(question) + } + return "\n" + }) +} + +func (c *GitCommand) FastForward(branchName string, remoteName string, remoteBranchName string, promptUserForCredential func(string) string) error { + command := fmt.Sprintf("git fetch %s %s:%s", remoteName, remoteBranchName, branchName) + return c.OSCommand.DetectUnamePass(command, promptUserForCredential) +} + +func (c *GitCommand) FetchRemote(remoteName string, promptUserForCredential func(string) string) error { + command := fmt.Sprintf("git fetch %s", remoteName) + return c.OSCommand.DetectUnamePass(command, promptUserForCredential) +} diff --git a/pkg/commands/tags.go b/pkg/commands/tags.go new file mode 100644 index 000000000..f7b26d8df --- /dev/null +++ b/pkg/commands/tags.go @@ -0,0 +1,13 @@ +package commands + +func (c *GitCommand) CreateLightweightTag(tagName string, commitSha string) error { + return c.OSCommand.RunCommand("git tag %s %s", tagName, commitSha) +} + +func (c *GitCommand) DeleteTag(tagName string) error { + return c.OSCommand.RunCommand("git tag -d %s", tagName) +} + +func (c *GitCommand) PushTag(remoteName string, tagName string) error { + return c.OSCommand.RunCommand("git push %s %s", remoteName, tagName) +} diff --git a/pkg/gui/rebase_options_panel.go b/pkg/gui/rebase_options_panel.go index 9b11dccb1..2188f9fee 100644 --- a/pkg/gui/rebase_options_panel.go +++ b/pkg/gui/rebase_options_panel.go @@ -53,7 +53,7 @@ func (gui *Gui) genericMergeCommand(command string) error { } return nil } - result := gui.GitCommand.GenericMerge(commandType, command) + result := gui.GitCommand.GenericMergeOrRebaseAction(commandType, command) if err := gui.handleGenericMergeCommandResult(result); err != nil { return err } diff --git a/pkg/gui/remotes_panel.go b/pkg/gui/remotes_panel.go index 2d0827592..e310cebe7 100644 --- a/pkg/gui/remotes_panel.go +++ b/pkg/gui/remotes_panel.go @@ -161,9 +161,9 @@ func (gui *Gui) handleFetchRemote(g *gocui.Gui, v *gocui.View) error { gui.State.FetchMutex.Lock() defer gui.State.FetchMutex.Unlock() - if err := gui.GitCommand.FetchRemote(remote.Name); err != nil { - return err - } + // TODO: test this + err := gui.GitCommand.FetchRemote(remote.Name, gui.promptUserForCredential) + gui.handleCredentialsPopup(err) return gui.refreshSidePanels(refreshOptions{scope: []int{BRANCHES, REMOTES}}) })