From 72af7e41778bca93d82fa668641f515fba1d92bc Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 29 Sep 2020 20:03:39 +1000 Subject: factor out code from git.go --- pkg/commands/branch_list_builder.go | 161 ----- pkg/commands/branches.go | 157 ++++ pkg/commands/commit_list_builder.go | 381 ---------- pkg/commands/commit_list_builder_test.go | 112 --- pkg/commands/commits.go | 93 +++ pkg/commands/config.go | 52 ++ pkg/commands/files.go | 270 +++++++ pkg/commands/git.go | 1141 ++---------------------------- pkg/commands/git_test.go | 2 +- pkg/commands/loading_branches.go | 161 +++++ pkg/commands/loading_commits.go | 381 ++++++++++ pkg/commands/loading_commits_test.go | 112 +++ 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 +- 22 files changed, 1822 insertions(+), 1766 deletions(-) delete mode 100644 pkg/commands/branch_list_builder.go create mode 100644 pkg/commands/branches.go delete mode 100644 pkg/commands/commit_list_builder.go delete mode 100644 pkg/commands/commit_list_builder_test.go create mode 100644 pkg/commands/commits.go create mode 100644 pkg/commands/config.go create mode 100644 pkg/commands/files.go create mode 100644 pkg/commands/loading_branches.go create mode 100644 pkg/commands/loading_commits.go create mode 100644 pkg/commands/loading_commits_test.go 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 (limited to 'pkg') diff --git a/pkg/commands/branch_list_builder.go b/pkg/commands/branch_list_builder.go deleted file mode 100644 index 99c60d9c9..000000000 --- a/pkg/commands/branch_list_builder.go +++ /dev/null @@ -1,161 +0,0 @@ -package commands - -import ( - "regexp" - "strings" - - "github.com/jesseduffield/lazygit/pkg/models" - "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/sirupsen/logrus" -) - -// context: -// we want to only show 'safe' branches (ones that haven't e.g. been deleted) -// which `git branch -a` gives us, but we also want the recency data that -// git reflog gives us. -// So we get the HEAD, then append get the reflog branches that intersect with -// our safe branches, then add the remaining safe branches, ensuring uniqueness -// along the way - -// if we find out we need to use one of these functions in the git.go file, we -// can just pull them out of here and put them there and then call them from in here - -// BranchListBuilder returns a list of Branch objects for the current repo -type BranchListBuilder struct { - Log *logrus.Entry - GitCommand *GitCommand - ReflogCommits []*models.Commit -} - -// NewBranchListBuilder builds a new branch list builder -func NewBranchListBuilder(log *logrus.Entry, gitCommand *GitCommand, reflogCommits []*models.Commit) (*BranchListBuilder, error) { - return &BranchListBuilder{ - Log: log, - GitCommand: gitCommand, - ReflogCommits: reflogCommits, - }, nil -} - -func (b *BranchListBuilder) obtainBranches() []*models.Branch { - cmdStr := `git for-each-ref --sort=-committerdate --format="%(HEAD)|%(refname:short)|%(upstream:short)|%(upstream:track)" refs/heads` - output, err := b.GitCommand.OSCommand.RunCommandWithOutput(cmdStr) - if err != nil { - panic(err) - } - - trimmedOutput := strings.TrimSpace(output) - outputLines := strings.Split(trimmedOutput, "\n") - branches := make([]*models.Branch, 0, len(outputLines)) - for _, line := range outputLines { - if line == "" { - continue - } - - split := strings.Split(line, SEPARATION_CHAR) - - name := strings.TrimPrefix(split[1], "heads/") - branch := &models.Branch{ - Name: name, - Pullables: "?", - Pushables: "?", - Head: split[0] == "*", - } - - upstreamName := split[2] - if upstreamName == "" { - branches = append(branches, branch) - continue - } - - branch.UpstreamName = upstreamName - - track := split[3] - re := regexp.MustCompile(`ahead (\d+)`) - match := re.FindStringSubmatch(track) - if len(match) > 1 { - branch.Pushables = match[1] - } else { - branch.Pushables = "0" - } - - re = regexp.MustCompile(`behind (\d+)`) - match = re.FindStringSubmatch(track) - if len(match) > 1 { - branch.Pullables = match[1] - } else { - branch.Pullables = "0" - } - - branches = append(branches, branch) - } - - return branches -} - -// Build the list of branches for the current repo -func (b *BranchListBuilder) Build() []*models.Branch { - branches := b.obtainBranches() - - reflogBranches := b.obtainReflogBranches() - - // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches - branchesWithRecency := make([]*models.Branch, 0) -outer: - for _, reflogBranch := range reflogBranches { - for j, branch := range branches { - if branch.Head { - continue - } - if strings.EqualFold(reflogBranch.Name, branch.Name) { - branch.Recency = reflogBranch.Recency - branchesWithRecency = append(branchesWithRecency, branch) - branches = append(branches[0:j], branches[j+1:]...) - continue outer - } - } - } - - branches = append(branchesWithRecency, branches...) - - foundHead := false - for i, branch := range branches { - if branch.Head { - foundHead = true - branch.Recency = " *" - branches = append(branches[0:i], branches[i+1:]...) - branches = append([]*models.Branch{branch}, branches...) - break - } - } - if !foundHead { - currentBranchName, currentBranchDisplayName, err := b.GitCommand.CurrentBranchName() - if err != nil { - panic(err) - } - branches = append([]*models.Branch{{Name: currentBranchName, DisplayName: currentBranchDisplayName, Head: true, Recency: " *"}}, branches...) - } - return branches -} - -// TODO: only look at the new reflog commits, and otherwise store the recencies in -// int form against the branch to recalculate the time ago -func (b *BranchListBuilder) obtainReflogBranches() []*models.Branch { - foundBranchesMap := map[string]bool{} - re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`) - reflogBranches := make([]*models.Branch, 0, len(b.ReflogCommits)) - for _, commit := range b.ReflogCommits { - if match := re.FindStringSubmatch(commit.Name); len(match) == 3 { - recency := utils.UnixToTimeAgo(commit.UnixTimestamp) - for _, branchName := range match[1:] { - if !foundBranchesMap[branchName] { - foundBranchesMap[branchName] = true - reflogBranches = append(reflogBranches, &models.Branch{ - Recency: recency, - Name: branchName, - }) - } - } - } - } - return reflogBranches -} 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/commit_list_builder.go b/pkg/commands/commit_list_builder.go deleted file mode 100644 index d29e03a28..000000000 --- a/pkg/commands/commit_list_builder.go +++ /dev/null @@ -1,381 +0,0 @@ -package commands - -import ( - "fmt" - "io/ioutil" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/fatih/color" - "github.com/jesseduffield/lazygit/pkg/commands/oscommands" - "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/jesseduffield/lazygit/pkg/models" - "github.com/sirupsen/logrus" -) - -// context: -// here we get the commits from git log but format them to show whether they're -// unpushed/pushed/merged into the base branch or not, or if they're yet to -// be processed as part of a rebase (these won't appear in git log but we -// grab them from the rebase-related files in the .git directory to show them - -// if we find out we need to use one of these functions in the git.go file, we -// can just pull them out of here and put them there and then call them from in here - -const SEPARATION_CHAR = "|" - -// CommitListBuilder returns a list of Branch objects for the current repo -type CommitListBuilder struct { - Log *logrus.Entry - GitCommand *GitCommand - OSCommand *oscommands.OSCommand - Tr *i18n.Localizer -} - -// NewCommitListBuilder builds a new commit list builder -func NewCommitListBuilder(log *logrus.Entry, gitCommand *GitCommand, osCommand *oscommands.OSCommand, tr *i18n.Localizer) *CommitListBuilder { - return &CommitListBuilder{ - Log: log, - GitCommand: gitCommand, - OSCommand: osCommand, - Tr: tr, - } -} - -// extractCommitFromLine takes a line from a git log and extracts the sha, message, date, and tag if present -// then puts them into a commit object -// example input: -// 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag -func (c *CommitListBuilder) extractCommitFromLine(line string) *models.Commit { - split := strings.Split(line, SEPARATION_CHAR) - - sha := split[0] - unixTimestamp := split[1] - author := split[2] - extraInfo := strings.TrimSpace(split[3]) - parentHashes := split[4] - - message := strings.Join(split[5:], SEPARATION_CHAR) - tags := []string{} - - if extraInfo != "" { - re := regexp.MustCompile(`tag: ([^,\)]+)`) - tagMatch := re.FindStringSubmatch(extraInfo) - if len(tagMatch) > 1 { - tags = append(tags, tagMatch[1]) - } - } - - unitTimestampInt, _ := strconv.Atoi(unixTimestamp) - - // Any commit with multiple parents is a merge commit. - // If there's a space then it means there must be more than one parent hash - isMerge := strings.Contains(parentHashes, " ") - - return &models.Commit{ - Sha: sha, - Name: message, - Tags: tags, - ExtraInfo: extraInfo, - UnixTimestamp: int64(unitTimestampInt), - Author: author, - IsMerge: isMerge, - } -} - -type GetCommitsOptions struct { - Limit bool - FilterPath string - IncludeRebaseCommits bool - RefName string // e.g. "HEAD" or "my_branch" -} - -func (c *CommitListBuilder) MergeRebasingCommits(commits []*models.Commit) ([]*models.Commit, error) { - // chances are we have as many commits as last time so we'll set the capacity to be the old length - result := make([]*models.Commit, 0, len(commits)) - for i, commit := range commits { - if commit.Status != "rebasing" { // removing the existing rebase commits so we can add the refreshed ones - result = append(result, commits[i:]...) - break - } - } - - rebaseMode, err := c.GitCommand.RebaseMode() - if err != nil { - return nil, err - } - - if rebaseMode == "" { - // not in rebase mode so return original commits - return result, nil - } - - rebasingCommits, err := c.getRebasingCommits(rebaseMode) - if err != nil { - return nil, err - } - if len(rebasingCommits) > 0 { - result = append(rebasingCommits, result...) - } - - return result, nil -} - -// GetCommits obtains the commits of the current branch -func (c *CommitListBuilder) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) { - commits := []*models.Commit{} - var rebasingCommits []*models.Commit - rebaseMode, err := c.GitCommand.RebaseMode() - if err != nil { - return nil, err - } - - if opts.IncludeRebaseCommits && opts.FilterPath == "" { - var err error - rebasingCommits, err = c.MergeRebasingCommits(commits) - if err != nil { - return nil, err - } - commits = append(commits, rebasingCommits...) - } - - passedFirstPushedCommit := false - firstPushedCommit, err := c.getFirstPushedCommit(opts.RefName) - if err != nil { - // must have no upstream branch so we'll consider everything as pushed - passedFirstPushedCommit = true - } - - cmd := c.getLogCmd(opts) - - err = oscommands.RunLineOutputCmd(cmd, func(line string) (bool, error) { - if strings.Split(line, " ")[0] != "gpg:" { - commit := c.extractCommitFromLine(line) - if commit.Sha == firstPushedCommit { - passedFirstPushedCommit = true - } - commit.Status = map[bool]string{true: "unpushed", false: "pushed"}[!passedFirstPushedCommit] - commits = append(commits, commit) - } - return false, nil - }) - if err != nil { - return nil, err - } - - if rebaseMode != "" { - currentCommit := commits[len(rebasingCommits)] - blue := color.New(color.FgYellow) - youAreHere := blue.Sprintf("<-- %s ---", c.Tr.SLocalize("YouAreHere")) - currentCommit.Name = fmt.Sprintf("%s %s", youAreHere, currentCommit.Name) - } - - commits, err = c.setCommitMergedStatuses(opts.RefName, commits) - if err != nil { - return nil, err - } - - return commits, nil -} - -// getRebasingCommits obtains the commits that we're in the process of rebasing -func (c *CommitListBuilder) getRebasingCommits(rebaseMode string) ([]*models.Commit, error) { - switch rebaseMode { - case "normal": - return c.getNormalRebasingCommits() - case "interactive": - return c.getInteractiveRebasingCommits() - default: - return nil, nil - } -} - -func (c *CommitListBuilder) getNormalRebasingCommits() ([]*models.Commit, error) { - rewrittenCount := 0 - bytesContent, err := ioutil.ReadFile(filepath.Join(c.GitCommand.DotGitDir, "rebase-apply/rewritten")) - if err == nil { - content := string(bytesContent) - rewrittenCount = len(strings.Split(content, "\n")) - } - - // we know we're rebasing, so lets get all the files whose names have numbers - commits := []*models.Commit{} - err = filepath.Walk(filepath.Join(c.GitCommand.DotGitDir, "rebase-apply"), func(path string, f os.FileInfo, err error) error { - if rewrittenCount > 0 { - rewrittenCount-- - return nil - } - if err != nil { - return err - } - re := regexp.MustCompile(`^\d+$`) - if !re.MatchString(f.Name()) { - return nil - } - bytesContent, err := ioutil.ReadFile(path) - if err != nil { - return err - } - content := string(bytesContent) - commit, err := c.commitFromPatch(content) - if err != nil { - return err - } - commits = append([]*models.Commit{commit}, commits...) - return nil - }) - if err != nil { - return nil, err - } - - return commits, nil -} - -// git-rebase-todo example: -// pick ac446ae94ee560bdb8d1d057278657b251aaef17 ac446ae -// pick afb893148791a2fbd8091aeb81deba4930c73031 afb8931 - -// git-rebase-todo.backup example: -// pick 49cbba374296938ea86bbd4bf4fee2f6ba5cccf6 third commit on master -// pick ac446ae94ee560bdb8d1d057278657b251aaef17 blah commit on master -// pick afb893148791a2fbd8091aeb81deba4930c73031 fourth commit on master - -// getInteractiveRebasingCommits takes our git-rebase-todo and our git-rebase-todo.backup files -// and extracts out the sha and names of commits that we still have to go -// in the rebase: -func (c *CommitListBuilder) getInteractiveRebasingCommits() ([]*models.Commit, error) { - bytesContent, err := ioutil.ReadFile(filepath.Join(c.GitCommand.DotGitDir, "rebase-merge/git-rebase-todo")) - if err != nil { - c.Log.Error(fmt.Sprintf("error occurred reading git-rebase-todo: %s", err.Error())) - // we assume an error means the file doesn't exist so we just return - return nil, nil - } - - commits := []*models.Commit{} - lines := strings.Split(string(bytesContent), "\n") - for _, line := range lines { - if line == "" || line == "noop" { - return commits, nil - } - if strings.HasPrefix(line, "#") { - continue - } - splitLine := strings.Split(line, " ") - commits = append([]*models.Commit{{ - Sha: splitLine[1], - Name: strings.Join(splitLine[2:], " "), - Status: "rebasing", - Action: splitLine[0], - }}, commits...) - } - - return commits, nil -} - -// assuming the file starts like this: -// From e93d4193e6dd45ca9cf3a5a273d7ba6cd8b8fb20 Mon Sep 17 00:00:00 2001 -// From: Lazygit Tester -// Date: Wed, 5 Dec 2018 21:03:23 +1100 -// Subject: second commit on master -func (c *CommitListBuilder) commitFromPatch(content string) (*models.Commit, error) { - lines := strings.Split(content, "\n") - sha := strings.Split(lines[0], " ")[1] - name := strings.TrimPrefix(lines[3], "Subject: ") - return &models.Commit{ - Sha: sha, - Name: name, - Status: "rebasing", - }, nil -} - -func (c *CommitListBuilder) setCommitMergedStatuses(refName string, commits []*models.Commit) ([]*models.Commit, error) { - ancestor, err := c.getMergeBase(refName) - if err != nil { - return nil, err - } - if ancestor == "" { - return commits, nil - } - passedAncestor := false - for i, commit := range commits { - if strings.HasPrefix(ancestor, commit.Sha) { - passedAncestor = true - } - if commit.Status != "pushed" { - continue - } - if passedAncestor { - commits[i].Status = "merged" - } - } - return commits, nil -} - -func (c *CommitListBuilder) getMergeBase(refName string) (string, error) { - currentBranch, _, err := c.GitCommand.CurrentBranchName() - if err != nil { - return "", err - } - - baseBranch := "master" - if strings.HasPrefix(currentBranch, "feature/") { - baseBranch = "develop" - } - - // swallowing error because it's not a big deal; probably because there are no commits yet - output, _ := c.OSCommand.RunCommandWithOutput("git merge-base %s %s", refName, baseBranch) - return ignoringWarnings(output), nil -} - -func ignoringWarnings(commandOutput string) string { - trimmedOutput := strings.TrimSpace(commandOutput) - split := strings.Split(trimmedOutput, "\n") - // need to get last line in case the first line is a warning about how the error is ambiguous. - // At some point we should find a way to make it unambiguous - lastLine := split[len(split)-1] - - return lastLine -} - -// getFirstPushedCommit returns the first commit SHA which has been pushed to the ref's upstream. -// all commits above this are deemed unpushed and marked as such. -func (c *CommitListBuilder) getFirstPushedCommit(refName string) (string, error) { - output, err := c.OSCommand.RunCommandWithOutput("git merge-base %s %s@{u}", refName, refName) - if err != nil { - return "", err - } - - return ignoringWarnings(output), nil -} - -// getLog gets the git log. -func (c *CommitListBuilder) getLogCmd(opts GetCommitsOptions) *exec.Cmd { - limitFlag := "" - if opts.Limit { - limitFlag = "-300" - } - - filterFlag := "" - if opts.FilterPath != "" { - filterFlag = fmt.Sprintf(" --follow -- %s", c.OSCommand.Quote(opts.FilterPath)) - } - - return c.OSCommand.ExecutableFromString( - fmt.Sprintf( - "git log %s --oneline --pretty=format:\"%%H%s%%at%s%%aN%s%%d%s%%p%s%%s\" %s --abbrev=%d --date=unix %s", - opts.RefName, - SEPARATION_CHAR, - SEPARATION_CHAR, - SEPARATION_CHAR, - SEPARATION_CHAR, - SEPARATION_CHAR, - limitFlag, - 20, - filterFlag, - ), - ) -} diff --git a/pkg/commands/commit_list_builder_test.go b/pkg/commands/commit_list_builder_test.go deleted file mode 100644 index 1c35f6c63..000000000 --- a/pkg/commands/commit_list_builder_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package commands - -import ( - "os/exec" - "testing" - - "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/stretchr/testify/assert" -) - -// NewDummyCommitListBuilder creates a new dummy CommitListBuilder for testing -func NewDummyCommitListBuilder() *CommitListBuilder { - osCommand := NewDummyOSCommand() - - return &CommitListBuilder{ - Log: utils.NewDummyLog(), - GitCommand: NewDummyGitCommandWithOSCommand(osCommand), - OSCommand: osCommand, - Tr: i18n.NewLocalizer(utils.NewDummyLog()), - } -} - -// TestCommitListBuilderGetMergeBase is a function. -func TestCommitListBuilderGetMergeBase(t *testing.T) { - type scenario struct { - testName string - command func(string, ...string) *exec.Cmd - test func(string, error) - } - - scenarios := []scenario{ - { - "swallows an error if the call to merge-base returns an error", - func(cmd string, args ...string) *exec.Cmd { - assert.EqualValues(t, "git", cmd) - - switch args[0] { - case "symbolic-ref": - assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) - return exec.Command("echo", "master") - case "merge-base": - assert.EqualValues(t, []string{"merge-base", "HEAD", "master"}, args) - return exec.Command("test") - } - return nil - }, - func(output string, err error) { - assert.NoError(t, err) - assert.EqualValues(t, "", output) - }, - }, - { - "returns the commit when master", - func(cmd string, args ...string) *exec.Cmd { - assert.EqualValues(t, "git", cmd) - - switch args[0] { - case "symbolic-ref": - assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) - return exec.Command("echo", "master") - case "merge-base": - assert.EqualValues(t, []string{"merge-base", "HEAD", "master"}, args) - return exec.Command("echo", "blah") - } - return nil - }, - func(output string, err error) { - assert.NoError(t, err) - assert.Equal(t, "blah", output) - }, - }, - { - "checks against develop when a feature branch", - func(cmd string, args ...string) *exec.Cmd { - assert.EqualValues(t, "git", cmd) - - switch args[0] { - case "symbolic-ref": - assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) - return exec.Command("echo", "feature/test") - case "merge-base": - assert.EqualValues(t, []string{"merge-base", "HEAD", "develop"}, args) - return exec.Command("echo", "blah") - } - return nil - }, - func(output string, err error) { - assert.NoError(t, err) - assert.Equal(t, "blah", output) - }, - }, - { - "bubbles up error if there is one", - func(cmd string, args ...string) *exec.Cmd { - return exec.Command("test") - }, - func(output string, err error) { - assert.Error(t, err) - assert.Equal(t, "", output) - }, - }, - } - - for _, s := range scenarios { - t.Run(s.testName, func(t *testing.T) { - c := NewDummyCommitListBuilder() - c.OSCommand.SetCommand(s.command) - s.test(c.getMergeBase("HEAD")) - }) - } -} 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,83 +25,6 @@ import ( // and returns '264fc6f5' as the second match const CurrentBranchNameRegex = `(?m)^\*.*?([^ ]*?)\)?$` -func verifyInGitRepo(runCmd func(string, ...interface{}) error) error { - return runCmd("git status") -} - -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) - } - } -} - -// 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), sLocalize func(string) 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(sLocalize("GitconfigParseErr")) - } - - return nil, err - } - - return repository, err -} - // GitCommand is our main git interface type GitCommand struct { Log *logrus.Entry @@ -176,1060 +91,104 @@ func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n. 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 - } - - 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 -} - -// 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 +func verifyInGitRepo(runCmd func(string, ...interface{}) error) error { + return runCmd("git status") } -// 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 +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) } - } - - 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 nil } - 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" + // 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) - if opts.RemoteName != "" { - command = fmt.Sprintf("%s %s", command, opts.RemoteName) - } - if opts.BranchName != "" { - command = fmt.Sprintf("%s %s", command, opts.BranchName) - } + for { + _, err := stat(".git") - return c.OSCommand.DetectUnamePass(command, func(question string) string { - if opts.PromptUserForCredential != nil { - return opts.PromptUserForCredential(question) + if err == nil { + return nil } - 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 + if !os.IsNotExist(err) { + return utils.WrapError(err) } - } - 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 + if err = chdir(".."); err != nil { + return utils.WrapError(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")) +// 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 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") + if l.Mode()&os.ModeSymlink == 0 { + return path, nil } - return beforeFile, afterFile, nil + return filepath.EvalSymlinks(path) } -// DiscardAllFileChanges directly -func (c *GitCommand) DiscardAllFileChanges(file *models.File) error { - if file.IsRename() { - beforeFile, afterFile, err := c.BeforeAndAfterFileForRename(file) +func setupRepository(openGitRepository func(string) (*gogit.Repository, error), sLocalize func(string) string) (*gogit.Repository, error) { + unresolvedPath := env.GetGitDirEnv() + if unresolvedPath == "" { + var err error + unresolvedPath, err = os.Getwd() 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 + return nil, 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") + path, err := resolvePath(unresolvedPath) 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])