From d5e443e8e3609fe38586aed942a3dae3343dbe47 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Mon, 4 Nov 2019 19:47:25 +1100 Subject: Support building and moving patches WIP --- pkg/commands/branch_list_builder.go | 164 ++++++++++ pkg/commands/commit_file.go | 31 +- pkg/commands/commit_list_builder.go | 295 ++++++++++++++++++ pkg/commands/commit_list_builder_test.go | 314 +++++++++++++++++++ pkg/commands/git.go | 123 +++++--- pkg/commands/git_test.go | 13 +- pkg/commands/patch_manager.go | 194 ++++++++++++ pkg/commands/patch_modifier.go | 260 ++++++++++++++++ pkg/commands/patch_modifier_test.go | 511 +++++++++++++++++++++++++++++++ pkg/commands/patch_parser.go | 209 +++++++++++++ pkg/commands/patch_rebases.go | 153 +++++++++ 11 files changed, 2216 insertions(+), 51 deletions(-) create mode 100644 pkg/commands/branch_list_builder.go create mode 100644 pkg/commands/commit_list_builder.go create mode 100644 pkg/commands/commit_list_builder_test.go create mode 100644 pkg/commands/patch_manager.go create mode 100644 pkg/commands/patch_modifier.go create mode 100644 pkg/commands/patch_modifier_test.go create mode 100644 pkg/commands/patch_parser.go create mode 100644 pkg/commands/patch_rebases.go (limited to 'pkg/commands') diff --git a/pkg/commands/branch_list_builder.go b/pkg/commands/branch_list_builder.go new file mode 100644 index 000000000..d7a232055 --- /dev/null +++ b/pkg/commands/branch_list_builder.go @@ -0,0 +1,164 @@ +package commands + +import ( + "regexp" + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" + + "github.com/sirupsen/logrus" + + "gopkg.in/src-d/go-git.v4/plumbing" +) + +// 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 +} + +// NewBranchListBuilder builds a new branch list builder +func NewBranchListBuilder(log *logrus.Entry, gitCommand *GitCommand) (*BranchListBuilder, error) { + return &BranchListBuilder{ + Log: log, + GitCommand: gitCommand, + }, nil +} + +func (b *BranchListBuilder) obtainCurrentBranch() *Branch { + branchName, err := b.GitCommand.CurrentBranchName() + if err != nil { + panic(err.Error()) + } + + return &Branch{Name: strings.TrimSpace(branchName)} +} + +func (b *BranchListBuilder) obtainReflogBranches() []*Branch { + branches := make([]*Branch, 0) + rawString, err := b.GitCommand.OSCommand.RunCommandWithOutput("git reflog -n100 --pretty='%cr|%gs' --grep-reflog='checkout: moving' HEAD") + if err != nil { + return branches + } + + branchLines := utils.SplitLines(rawString) + for _, line := range branchLines { + timeNumber, timeUnit, branchName := branchInfoFromLine(line) + timeUnit = abbreviatedTimeUnit(timeUnit) + branch := &Branch{Name: branchName, Recency: timeNumber + timeUnit} + branches = append(branches, branch) + } + return uniqueByName(branches) +} + +func (b *BranchListBuilder) obtainSafeBranches() []*Branch { + branches := make([]*Branch, 0) + + bIter, err := b.GitCommand.Repo.Branches() + if err != nil { + panic(err) + } + bIter.ForEach(func(b *plumbing.Reference) error { + name := b.Name().Short() + branches = append(branches, &Branch{Name: name}) + return nil + }) + + return branches +} + +func (b *BranchListBuilder) appendNewBranches(finalBranches, newBranches, existingBranches []*Branch, included bool) []*Branch { + for _, newBranch := range newBranches { + if included == branchIncluded(newBranch.Name, existingBranches) { + finalBranches = append(finalBranches, newBranch) + } + } + return finalBranches +} + +func sanitisedReflogName(reflogBranch *Branch, safeBranches []*Branch) string { + for _, safeBranch := range safeBranches { + if strings.ToLower(safeBranch.Name) == strings.ToLower(reflogBranch.Name) { + return safeBranch.Name + } + } + return reflogBranch.Name +} + +// Build the list of branches for the current repo +func (b *BranchListBuilder) Build() []*Branch { + branches := make([]*Branch, 0) + head := b.obtainCurrentBranch() + safeBranches := b.obtainSafeBranches() + + reflogBranches := b.obtainReflogBranches() + for i, reflogBranch := range reflogBranches { + reflogBranches[i].Name = sanitisedReflogName(reflogBranch, safeBranches) + } + + branches = b.appendNewBranches(branches, reflogBranches, safeBranches, true) + branches = b.appendNewBranches(branches, safeBranches, branches, false) + + if len(branches) == 0 || branches[0].Name != head.Name { + branches = append([]*Branch{head}, branches...) + } + + branches[0].Recency = " *" + + return branches +} + +func branchIncluded(branchName string, branches []*Branch) bool { + for _, existingBranch := range branches { + if strings.ToLower(existingBranch.Name) == strings.ToLower(branchName) { + return true + } + } + return false +} + +func uniqueByName(branches []*Branch) []*Branch { + finalBranches := make([]*Branch, 0) + for _, branch := range branches { + if branchIncluded(branch.Name, finalBranches) { + continue + } + finalBranches = append(finalBranches, branch) + } + return finalBranches +} + +// A line will have the form '10 days ago master' so we need to strip out the +// useful information from that into timeNumber, timeUnit, and branchName +func branchInfoFromLine(line string) (string, string, string) { + r := regexp.MustCompile("\\|.*\\s") + line = r.ReplaceAllString(line, " ") + words := strings.Split(line, " ") + return words[0], words[1], words[len(words)-1] +} + +func abbreviatedTimeUnit(timeUnit string) string { + r := regexp.MustCompile("s$") + timeUnit = r.ReplaceAllString(timeUnit, "") + timeUnitMap := map[string]string{ + "hour": "h", + "minute": "m", + "second": "s", + "week": "w", + "year": "y", + "day": "d", + "month": "m", + } + return timeUnitMap[timeUnit] +} diff --git a/pkg/commands/commit_file.go b/pkg/commands/commit_file.go index 8bc6a11c2..ddd09b23b 100644 --- a/pkg/commands/commit_file.go +++ b/pkg/commands/commit_file.go @@ -1,13 +1,42 @@ package commands +import ( + "github.com/fatih/color" + "github.com/jesseduffield/lazygit/pkg/theme" +) + // CommitFile : A git commit file type CommitFile struct { Sha string Name string DisplayString string + Status int // one of 'WHOLE' 'PART' 'NONE' } +const ( + // UNSELECTED is for when the commit file has not been added to the patch in any way + UNSELECTED = iota + // WHOLE is for when you want to add the whole diff of a file to the patch, + // including e.g. if it was deleted + WHOLE = iota + // PART is for when you're only talking about specific lines that have been modified + PART +) + // GetDisplayStrings is a function. func (f *CommitFile) GetDisplayStrings(isFocused bool) []string { - return []string{f.DisplayString} + yellow := color.New(color.FgYellow) + green := color.New(color.FgGreen) + defaultColor := color.New(theme.DefaultTextColor) + + var colour *color.Color + switch f.Status { + case UNSELECTED: + colour = defaultColor + case WHOLE: + colour = green + case PART: + colour = yellow + } + return []string{colour.Sprint(f.DisplayString)} } diff --git a/pkg/commands/commit_list_builder.go b/pkg/commands/commit_list_builder.go new file mode 100644 index 000000000..aab6de6a3 --- /dev/null +++ b/pkg/commands/commit_list_builder.go @@ -0,0 +1,295 @@ +package commands + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/fatih/color" + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/jesseduffield/lazygit/pkg/utils" + "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 + +// CommitListBuilder returns a list of Branch objects for the current repo +type CommitListBuilder struct { + Log *logrus.Entry + GitCommand *GitCommand + OSCommand *OSCommand + Tr *i18n.Localizer + CherryPickedCommits []*Commit + DiffEntries []*Commit +} + +// NewCommitListBuilder builds a new commit list builder +func NewCommitListBuilder(log *logrus.Entry, gitCommand *GitCommand, osCommand *OSCommand, tr *i18n.Localizer, cherryPickedCommits []*Commit, diffEntries []*Commit) (*CommitListBuilder, error) { + return &CommitListBuilder{ + Log: log, + GitCommand: gitCommand, + OSCommand: osCommand, + Tr: tr, + CherryPickedCommits: cherryPickedCommits, + DiffEntries: diffEntries, + }, nil +} + +// GetCommits obtains the commits of the current branch +func (c *CommitListBuilder) GetCommits() ([]*Commit, error) { + commits := []*Commit{} + var rebasingCommits []*Commit + rebaseMode, err := c.GitCommand.RebaseMode() + if err != nil { + return nil, err + } + if rebaseMode != "" { + // here we want to also prepend the commits that we're in the process of rebasing + rebasingCommits, err = c.getRebasingCommits(rebaseMode) + if err != nil { + return nil, err + } + if len(rebasingCommits) > 0 { + commits = append(commits, rebasingCommits...) + } + } + + unpushedCommits := c.getUnpushedCommits() + log := c.getLog() + + // now we can split it up and turn it into commits + for _, line := range utils.SplitLines(log) { + splitLine := strings.Split(line, " ") + sha := splitLine[0] + _, unpushed := unpushedCommits[sha] + status := map[bool]string{true: "unpushed", false: "pushed"}[unpushed] + commits = append(commits, &Commit{ + Sha: sha, + Name: strings.Join(splitLine[1:], " "), + Status: status, + DisplayString: strings.Join(splitLine, " "), + }) + } + 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(commits) + if err != nil { + return nil, err + } + + commits, err = c.setCommitCherryPickStatuses(commits) + if err != nil { + return nil, err + } + + for _, commit := range commits { + for _, entry := range c.DiffEntries { + if entry.Sha == commit.Sha { + commit.Status = "selected" + } + } + } + + return commits, nil +} + +// getRebasingCommits obtains the commits that we're in the process of rebasing +func (c *CommitListBuilder) getRebasingCommits(rebaseMode string) ([]*Commit, error) { + switch rebaseMode { + case "normal": + return c.getNormalRebasingCommits() + case "interactive": + return c.getInteractiveRebasingCommits() + default: + return nil, nil + } +} + +func (c *CommitListBuilder) getNormalRebasingCommits() ([]*Commit, error) { + rewrittenCount := 0 + bytesContent, err := ioutil.ReadFile(fmt.Sprintf("%s/rebase-apply/rewritten", c.GitCommand.DotGitDir)) + 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 := []*Commit{} + err = filepath.Walk(fmt.Sprintf("%s/rebase-apply", c.GitCommand.DotGitDir), 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([]*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() ([]*Commit, error) { + bytesContent, err := ioutil.ReadFile(fmt.Sprintf("%s/rebase-merge/git-rebase-todo", c.GitCommand.DotGitDir)) + if err != nil { + c.Log.Info(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 := []*Commit{} + lines := strings.Split(string(bytesContent), "\n") + for _, line := range lines { + if line == "" || line == "noop" { + return commits, nil + } + splitLine := strings.Split(line, " ") + commits = append([]*Commit{{ + Sha: splitLine[1][0:7], + Name: strings.Join(splitLine[2:], " "), + Status: "rebasing", + Action: splitLine[0], + }}, commits...) + } + + return nil, 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) (*Commit, error) { + lines := strings.Split(content, "\n") + sha := strings.Split(lines[0], " ")[1][0:7] + name := strings.TrimPrefix(lines[3], "Subject: ") + return &Commit{ + Sha: sha, + Name: name, + Status: "rebasing", + }, nil +} + +func (c *CommitListBuilder) setCommitMergedStatuses(commits []*Commit) ([]*Commit, error) { + ancestor, err := c.getMergeBase() + 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) setCommitCherryPickStatuses(commits []*Commit) ([]*Commit, error) { + for _, commit := range commits { + for _, cherryPickedCommit := range c.CherryPickedCommits { + if commit.Sha == cherryPickedCommit.Sha { + commit.Copied = true + } + } + } + return commits, nil +} + +func (c *CommitListBuilder) getMergeBase() (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(fmt.Sprintf("git merge-base HEAD %s", baseBranch)) + return output, nil +} + +// getUnpushedCommits Returns the sha's of the commits that have not yet been pushed +// to the remote branch of the current branch, a map is returned to ease look up +func (c *CommitListBuilder) getUnpushedCommits() map[string]bool { + pushables := map[string]bool{} + o, err := c.OSCommand.RunCommandWithOutput("git rev-list @{u}..HEAD --abbrev-commit") + if err != nil { + return pushables + } + for _, p := range utils.SplitLines(o) { + pushables[p] = true + } + + return pushables +} + +// getLog gets the git log (currently limited to 30 commits for performance +// until we work out lazy loading +func (c *CommitListBuilder) getLog() string { + // currently limiting to 30 for performance reasons + // TODO: add lazyloading when you scroll down + result, err := c.OSCommand.RunCommandWithOutput("git log --oneline -30") + if err != nil { + // assume if there is an error there are no commits yet for this branch + return "" + } + + return result +} diff --git a/pkg/commands/commit_list_builder_test.go b/pkg/commands/commit_list_builder_test.go new file mode 100644 index 000000000..cdd360ce8 --- /dev/null +++ b/pkg/commands/commit_list_builder_test.go @@ -0,0 +1,314 @@ +package commands + +import ( + "os/exec" + "testing" + + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/stretchr/testify/assert" +) + +// NewDummyCommitListBuilder creates a new dummy CommitListBuilder for testing +func NewDummyCommitListBuilder() *CommitListBuilder { + osCommand := NewDummyOSCommand() + + return &CommitListBuilder{ + Log: NewDummyLog(), + GitCommand: NewDummyGitCommandWithOSCommand(osCommand), + OSCommand: osCommand, + Tr: i18n.NewLocalizer(NewDummyLog()), + CherryPickedCommits: []*Commit{}, + } +} + +// TestCommitListBuilderGetUnpushedCommits is a function. +func TestCommitListBuilderGetUnpushedCommits(t *testing.T) { + type scenario struct { + testName string + command func(string, ...string) *exec.Cmd + test func(map[string]bool) + } + + scenarios := []scenario{ + { + "Can't retrieve pushable commits", + func(string, ...string) *exec.Cmd { + return exec.Command("test") + }, + func(pushables map[string]bool) { + assert.EqualValues(t, map[string]bool{}, pushables) + }, + }, + { + "Retrieve pushable commits", + func(cmd string, args ...string) *exec.Cmd { + return exec.Command("echo", "8a2bb0e\n78976bc") + }, + func(pushables map[string]bool) { + assert.Len(t, pushables, 2) + assert.EqualValues(t, map[string]bool{"8a2bb0e": true, "78976bc": true}, pushables) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + c := NewDummyCommitListBuilder() + c.OSCommand.SetCommand(s.command) + s.test(c.getUnpushedCommits()) + }) + } +} + +// 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\n", 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\n", 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()) + }) + } +} + +// TestCommitListBuilderGetLog is a function. +func TestCommitListBuilderGetLog(t *testing.T) { + type scenario struct { + testName string + command func(string, ...string) *exec.Cmd + test func(string) + } + + scenarios := []scenario{ + { + "Retrieves logs", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + assert.EqualValues(t, []string{"log", "--oneline", "-30"}, args) + + return exec.Command("echo", "6f0b32f commands/git : add GetCommits tests refactor\n9d9d775 circle : remove new line") + }, + func(output string) { + assert.EqualValues(t, "6f0b32f commands/git : add GetCommits tests refactor\n9d9d775 circle : remove new line\n", output) + }, + }, + { + "An error occurred when retrieving logs", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + assert.EqualValues(t, []string{"log", "--oneline", "-30"}, args) + return exec.Command("test") + }, + func(output string) { + assert.Empty(t, output) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + c := NewDummyCommitListBuilder() + c.OSCommand.SetCommand(s.command) + s.test(c.getLog()) + }) + } +} + +// TestCommitListBuilderGetCommits is a function. +func TestCommitListBuilderGetCommits(t *testing.T) { + type scenario struct { + testName string + command func(string, ...string) *exec.Cmd + test func([]*Commit, error) + } + + scenarios := []scenario{ + { + "No data found", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + + switch args[0] { + case "rev-list": + assert.EqualValues(t, []string{"rev-list", "@{u}..HEAD", "--abbrev-commit"}, args) + return exec.Command("echo") + case "log": + assert.EqualValues(t, []string{"log", "--oneline", "-30"}, args) + return exec.Command("echo") + case "merge-base": + assert.EqualValues(t, []string{"merge-base", "HEAD", "master"}, args) + return exec.Command("test") + case "symbolic-ref": + assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) + return exec.Command("echo", "master") + } + + return nil + }, + func(commits []*Commit, err error) { + assert.NoError(t, err) + assert.Len(t, commits, 0) + }, + }, + { + "GetCommits returns 2 commits, 1 unpushed, the other merged", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + + switch args[0] { + case "rev-list": + assert.EqualValues(t, []string{"rev-list", "@{u}..HEAD", "--abbrev-commit"}, args) + return exec.Command("echo", "8a2bb0e") + case "log": + assert.EqualValues(t, []string{"log", "--oneline", "-30"}, args) + return exec.Command("echo", "8a2bb0e commit 1\n78976bc commit 2") + case "merge-base": + assert.EqualValues(t, []string{"merge-base", "HEAD", "master"}, args) + return exec.Command("echo", "78976bc") + case "symbolic-ref": + assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) + return exec.Command("echo", "master") + } + + return nil + }, + func(commits []*Commit, err error) { + assert.NoError(t, err) + assert.Len(t, commits, 2) + assert.EqualValues(t, []*Commit{ + { + Sha: "8a2bb0e", + Name: "commit 1", + Status: "unpushed", + DisplayString: "8a2bb0e commit 1", + }, + { + Sha: "78976bc", + Name: "commit 2", + Status: "merged", + DisplayString: "78976bc commit 2", + }, + }, commits) + }, + }, + { + "GetCommits bubbles up an error from setCommitMergedStatuses", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + + switch args[0] { + case "rev-list": + assert.EqualValues(t, []string{"rev-list", "@{u}..HEAD", "--abbrev-commit"}, args) + return exec.Command("echo", "8a2bb0e") + case "log": + assert.EqualValues(t, []string{"log", "--oneline", "-30"}, args) + return exec.Command("echo", "8a2bb0e commit 1\n78976bc commit 2") + case "merge-base": + assert.EqualValues(t, []string{"merge-base", "HEAD", "master"}, args) + return exec.Command("echo", "78976bc") + case "symbolic-ref": + assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args) + // here's where we are returning the error + return exec.Command("test") + case "rev-parse": + assert.EqualValues(t, []string{"rev-parse", "--short", "HEAD"}, args) + // here too + return exec.Command("test") + } + + return nil + }, + func(commits []*Commit, err error) { + assert.Error(t, err) + assert.Len(t, commits, 0) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + c := NewDummyCommitListBuilder() + c.OSCommand.SetCommand(s.command) + s.test(c.GetCommits()) + }) + } +} diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 6e86fe0b5..815c84423 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -63,16 +63,17 @@ func setupRepositoryAndWorktree(openGitRepository func(string) (*gogit.Repositor // GitCommand is our main git interface type GitCommand struct { - Log *logrus.Entry - OSCommand *OSCommand - Worktree *gogit.Worktree - 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 + Log *logrus.Entry + OSCommand *OSCommand + Worktree *gogit.Worktree + 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 } // NewGitCommand it runs git commands @@ -376,7 +377,7 @@ func (c *GitCommand) Commit(message string, flags string) (*exec.Cmd, error) { // AmendHead amends HEAD with whatever is staged in your working tree func (c *GitCommand) AmendHead() (*exec.Cmd, error) { - command := "git commit --amend --no-edit" + command := "git commit --amend --no-edit --allow-empty" if c.usingGpg() { return c.OSCommand.PrepareSubProcess(c.OSCommand.Platform.shell, c.OSCommand.Platform.shellArg, command), nil } @@ -530,7 +531,7 @@ func (c *GitCommand) Ignore(filename string) error { // Show shows the diff of a commit func (c *GitCommand) Show(sha string) (string, error) { - show, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("git show --color %s", sha)) + show, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("git show --color --no-renames %s", sha)) if err != nil { return "", err } @@ -605,11 +606,11 @@ func (c *GitCommand) Diff(file *File, plain bool, cached bool) string { return s } -func (c *GitCommand) ApplyPatch(patch string, reverse bool, cached bool) (string, error) { +func (c *GitCommand) ApplyPatch(patch string, reverse bool, cached bool, extraFlags string) error { filename, err := c.OSCommand.CreateTempFile("patch", patch) if err != nil { c.Log.Error(err) - return "", err + return err } defer func() { _ = c.OSCommand.Remove(filename) }() @@ -624,7 +625,7 @@ func (c *GitCommand) ApplyPatch(patch string, reverse bool, cached bool) (string cachedFlag = "--cached" } - return c.OSCommand.RunCommandWithOutput(fmt.Sprintf("git apply %s %s %s", cachedFlag, reverseFlag, c.OSCommand.Quote(filename))) + return c.OSCommand.RunCommand(fmt.Sprintf("git apply %s %s %s %s", cachedFlag, reverseFlag, extraFlags, c.OSCommand.Quote(filename))) } func (c *GitCommand) FastForward(branchName string) error { @@ -645,13 +646,29 @@ func (c *GitCommand) RunSkipEditorCommand(command string) error { // 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 { - return c.RunSkipEditorCommand( + err := c.RunSkipEditorCommand( fmt.Sprintf( "git %s --%s", commandType, command, ), ) + if err != nil { + return 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 []*Commit, index int) (*exec.Cmd, error) { @@ -852,8 +869,8 @@ func (c *GitCommand) CherryPickCommits(commits []*Commit) error { } // GetCommitFiles get the specified commit files -func (c *GitCommand) GetCommitFiles(commitSha string) ([]*CommitFile, error) { - cmd := fmt.Sprintf("git show --pretty= --name-only %s", commitSha) +func (c *GitCommand) GetCommitFiles(commitSha string, patchManager *PatchManager) ([]*CommitFile, error) { + cmd := fmt.Sprintf("git show --pretty= --name-only --no-renames %s", commitSha) files, err := c.OSCommand.RunCommandWithOutput(cmd) if err != nil { return nil, err @@ -862,10 +879,16 @@ func (c *GitCommand) GetCommitFiles(commitSha string) ([]*CommitFile, error) { commitFiles := make([]*CommitFile, 0) for _, file := range strings.Split(strings.TrimRight(files, "\n"), "\n") { + status := UNSELECTED + if patchManager != nil && patchManager.CommitSha == commitSha { + status = patchManager.GetFileStatus(file) + } + commitFiles = append(commitFiles, &CommitFile{ Sha: commitSha, Name: file, DisplayString: file, + Status: status, }) } @@ -873,8 +896,12 @@ func (c *GitCommand) GetCommitFiles(commitSha string) ([]*CommitFile, error) { } // ShowCommitFile get the diff of specified commit file -func (c *GitCommand) ShowCommitFile(commitSha, fileName string) (string, error) { - cmd := fmt.Sprintf("git show --color %s -- %s", commitSha, fileName) +func (c *GitCommand) ShowCommitFile(commitSha, fileName string, plain bool) (string, error) { + colorArg := "--color" + if plain { + colorArg = "" + } + cmd := fmt.Sprintf("git show --no-renames %s %s -- %s", colorArg, commitSha, fileName) return c.OSCommand.RunCommandWithOutput(cmd) } @@ -886,28 +913,7 @@ func (c *GitCommand) CheckoutFile(commitSha, fileName string) error { // DiscardOldFileChanges discards changes to a file from an old commit func (c *GitCommand) DiscardOldFileChanges(commits []*Commit, commitIndex int, fileName string) 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 { + if err := c.BeginInteractiveRebaseForCommit(commits, commitIndex); err != nil { return err } @@ -924,7 +930,7 @@ func (c *GitCommand) DiscardOldFileChanges(commits []*Commit, commitIndex int, f } // amend the commit - cmd, err = c.AmendHead() + cmd, err := c.AmendHead() if cmd != nil { return errors.New("received unexpected pointer to cmd") } @@ -1016,3 +1022,34 @@ func (c *GitCommand) StashSaveStagedChanges(message string) error { 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 []*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 +} diff --git a/pkg/commands/git_test.go b/pkg/commands/git_test.go index c132de98a..52db798ed 100644 --- a/pkg/commands/git_test.go +++ b/pkg/commands/git_test.go @@ -1685,7 +1685,7 @@ func TestGitCommandApplyPatch(t *testing.T) { type scenario struct { testName string command func(string, ...string) *exec.Cmd - test func(string, error) + test func(error) } scenarios := []scenario{ @@ -1702,9 +1702,8 @@ func TestGitCommandApplyPatch(t *testing.T) { return exec.Command("echo", "done") }, - func(output string, err error) { + func(err error) { assert.NoError(t, err) - assert.EqualValues(t, "done\n", output) }, }, { @@ -1724,7 +1723,7 @@ func TestGitCommandApplyPatch(t *testing.T) { return exec.Command("test") }, - func(output string, err error) { + func(err error) { assert.Error(t, err) }, }, @@ -1734,7 +1733,7 @@ func TestGitCommandApplyPatch(t *testing.T) { t.Run(s.testName, func(t *testing.T) { gitCmd := NewDummyGitCommand() gitCmd.OSCommand.command = s.command - s.test(gitCmd.ApplyPatch("test", false, true)) + s.test(gitCmd.ApplyPatch("test", false, true, "")) }) } } @@ -1962,7 +1961,7 @@ func TestGitCommandShowCommitFile(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { gitCmd.OSCommand.command = s.command - s.test(gitCmd.ShowCommitFile(s.commitSha, s.fileName)) + s.test(gitCmd.ShowCommitFile(s.commitSha, s.fileName, true)) }) } } @@ -2001,7 +2000,7 @@ func TestGitCommandGetCommitFiles(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { gitCmd.OSCommand.command = s.command - s.test(gitCmd.GetCommitFiles(s.commitSha)) + s.test(gitCmd.GetCommitFiles(s.commitSha, nil)) }) } } diff --git a/pkg/commands/patch_manager.go b/pkg/commands/patch_manager.go new file mode 100644 index 000000000..7c0da245d --- /dev/null +++ b/pkg/commands/patch_manager.go @@ -0,0 +1,194 @@ +package commands + +import ( + "sort" + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/sirupsen/logrus" +) + +type fileInfo struct { + mode int // one of WHOLE/PART + includedLineIndices []int + diff string +} + +type applyPatchFunc func(patch string, reverse bool, cached bool, extraFlags string) error + +// PatchManager manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit) +type PatchManager struct { + CommitSha string + fileInfoMap map[string]*fileInfo + Log *logrus.Entry + ApplyPatch applyPatchFunc +} + +// NewPatchManager returns a new PatchModifier +func NewPatchManager(log *logrus.Entry, applyPatch applyPatchFunc, commitSha string, diffMap map[string]string) *PatchManager { + infoMap := map[string]*fileInfo{} + for filename, diff := range diffMap { + infoMap[filename] = &fileInfo{ + mode: UNSELECTED, + diff: diff, + } + } + + return &PatchManager{ + Log: log, + fileInfoMap: infoMap, + CommitSha: commitSha, + ApplyPatch: applyPatch, + } +} + +func (p *PatchManager) AddFile(filename string) { + p.fileInfoMap[filename].mode = WHOLE + p.fileInfoMap[filename].includedLineIndices = nil +} + +func (p *PatchManager) RemoveFile(filename string) { + p.fileInfoMap[filename].mode = UNSELECTED + p.fileInfoMap[filename].includedLineIndices = nil +} + +func (p *PatchManager) ToggleFileWhole(filename string) { + info := p.fileInfoMap[filename] + switch info.mode { + case UNSELECTED: + p.AddFile(filename) + case WHOLE: + p.RemoveFile(filename) + case PART: + p.AddFile(filename) + } +} + +func getIndicesForRange(first, last int) []int { + indices := []int{} + for i := first; i <= last; i++ { + indices = append(indices, i) + } + return indices +} + +func (p *PatchManager) AddFileLineRange(filename string, firstLineIdx, lastLineIdx int) { + info := p.fileInfoMap[filename] + info.mode = PART + info.includedLineIndices = utils.UnionInt(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx)) +} + +func (p *PatchManager) RemoveFileLineRange(filename string, firstLineIdx, lastLineIdx int) { + info := p.fileInfoMap[filename] + info.mode = PART + info.includedLineIndices = utils.DifferenceInt(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx)) + if len(info.includedLineIndices) == 0 { + p.RemoveFile(filename) + } +} + +func (p *PatchManager) RenderPlainPatchForFile(filename string, reverse bool) string { + info := p.fileInfoMap[filename] + if info == nil { + return "" + } + + switch info.mode { + case WHOLE: + // use the whole diff + // the reverse flag is only for part patches so we're ignoring it here + return info.diff + case PART: + // generate a new diff with just the selected lines + m := NewPatchModifier(p.Log, filename, info.diff) + return m.ModifiedPatchForLines(info.includedLineIndices, reverse, true) + default: + return "" + } +} + +func (p *PatchManager) RenderPatchForFile(filename string, plain bool, reverse bool) string { + patch := p.RenderPlainPatchForFile(filename, reverse) + if plain { + return patch + } + parser, err := NewPatchParser(p.Log, patch) + if err != nil { + // swallowing for now + return "" + } + // not passing included lines because we don't want to see them in the secondary panel + return parser.Render(-1, -1, nil) +} + +func (p *PatchManager) RenderEachFilePatch(plain bool) []string { + // sort files by name then iterate through and render each patch + filenames := make([]string, len(p.fileInfoMap)) + index := 0 + for filename := range p.fileInfoMap { + filenames[index] = filename + index++ + } + + sort.Strings(filenames) + output := []string{} + for _, filename := range filenames { + patch := p.RenderPatchForFile(filename, plain, false) + if patch != "" { + output = append(output, patch) + } + } + + return output +} + +func (p *PatchManager) RenderAggregatedPatchColored(plain bool) string { + return strings.Join(p.RenderEachFilePatch(plain), "\n") +} + +func (p *PatchManager) GetFileStatus(filename string) int { + info := p.fileInfoMap[filename] + if info == nil { + return UNSELECTED + } + return info.mode +} + +func (p *PatchManager) GetFileIncLineIndices(filename string) []int { + info := p.fileInfoMap[filename] + if info == nil { + return []int{} + } + return info.includedLineIndices +} + +func (p *PatchManager) ApplyPatches(reverse bool) error { + // for whole patches we'll apply the patch in reverse + // but for part patches we'll apply a reverse patch forwards + for filename, info := range p.fileInfoMap { + if info.mode == UNSELECTED { + continue + } + + reverseOnGenerate := false + reverseOnApply := false + if reverse { + if info.mode == WHOLE { + reverseOnApply = true + } else { + reverseOnGenerate = true + } + } + + patch := p.RenderPatchForFile(filename, true, reverseOnGenerate) + if patch == "" { + continue + } + p.Log.Warn(patch) + if err := p.ApplyPatch(patch, reverseOnApply, false, "--index --3way"); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/commands/patch_modifier.go b/pkg/commands/patch_modifier.go new file mode 100644 index 000000000..e407199c0 --- /dev/null +++ b/pkg/commands/patch_modifier.go @@ -0,0 +1,260 @@ +package commands + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/sirupsen/logrus" +) + +var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`) +var patchHeaderRegexp = regexp.MustCompile(`(?ms)(^diff.*?)^@@`) + +type PatchHunk struct { + header string + FirstLineIdx int + LastLineIdx int + bodyLines []string +} + +func newHunk(header string, body string, firstLineIdx int) *PatchHunk { + bodyLines := strings.SplitAfter(header+body, "\n")[1:] // dropping the header line + + return &PatchHunk{ + header: header, + FirstLineIdx: firstLineIdx, + LastLineIdx: firstLineIdx + len(bodyLines), + bodyLines: bodyLines, + } +} + +func (hunk *PatchHunk) updatedLines(lineIndices []int, reverse bool) []string { + skippedNewlineMessageIndex := -1 + newLines := []string{} + + lineIdx := hunk.FirstLineIdx + for _, line := range hunk.bodyLines { + lineIdx++ // incrementing at the start to skip the header line + if line == "" { + break + } + isLineSelected := utils.IncludesInt(lineIndices, lineIdx) + + firstChar, content := line[:1], line[1:] + transformedFirstChar := transformedFirstChar(firstChar, reverse, isLineSelected) + + if isLineSelected || (transformedFirstChar == "\\" && skippedNewlineMessageIndex != lineIdx) || transformedFirstChar == " " { + newLines = append(newLines, transformedFirstChar+content) + continue + } + + if transformedFirstChar == "+" { + // we don't want to include the 'newline at end of file' line if it involves an addition we're not including + skippedNewlineMessageIndex = lineIdx + 1 + } + } + + return newLines +} + +func transformedFirstChar(firstChar string, reverse bool, isLineSelected bool) string { + if reverse { + if !isLineSelected && firstChar == "+" { + return " " + } else if firstChar == "-" { + return "+" + } else if firstChar == "+" { + return "-" + } else { + return firstChar + } + } + + if !isLineSelected && firstChar == "-" { + return " " + } + + return firstChar +} + +func (hunk *PatchHunk) formatHeader(oldStart int, oldLength int, newStart int, newLength int, heading string) string { + return fmt.Sprintf("@@ -%d,%d +%d,%d @@%s\n", oldStart, oldLength, newStart, newLength, heading) +} + +func (hunk *PatchHunk) formatWithChanges(lineIndices []int, reverse bool, startOffset int) (int, string) { + bodyLines := hunk.updatedLines(lineIndices, reverse) + startOffset, header, ok := hunk.updatedHeader(bodyLines, startOffset, reverse) + if !ok { + return startOffset, "" + } + return startOffset, header + strings.Join(bodyLines, "") +} + +func (hunk *PatchHunk) updatedHeader(newBodyLines []string, startOffset int, reverse bool) (int, string, bool) { + changeCount := 0 + oldLength := 0 + newLength := 0 + for _, line := range newBodyLines { + switch line[:1] { + case "+": + newLength++ + changeCount++ + case "-": + oldLength++ + changeCount++ + case " ": + oldLength++ + newLength++ + } + } + + if changeCount == 0 { + // if nothing has changed we just return nothing + return startOffset, "", false + } + + // get oldstart, newstart, and heading from header + match := hunkHeaderRegexp.FindStringSubmatch(hunk.header) + + var oldStart int + if reverse { + oldStart = mustConvertToInt(match[2]) + } else { + oldStart = mustConvertToInt(match[1]) + } + heading := match[3] + + var newStartOffset int + // if the hunk went from zero to positive length, we need to increment the starting point by one + // if the hunk went from positive to zero length, we need to decrement the starting point by one + if oldLength == 0 { + newStartOffset = 1 + } else if newLength == 0 { + newStartOffset = -1 + } else { + newStartOffset = 0 + } + + newStart := oldStart + startOffset + newStartOffset + + newStartOffset = startOffset + newLength - oldLength + formattedHeader := hunk.formatHeader(oldStart, oldLength, newStart, newLength, heading) + return newStartOffset, formattedHeader, true +} + +func mustConvertToInt(s string) int { + i, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return i +} + +func GetHeaderFromDiff(diff string) string { + match := patchHeaderRegexp.FindStringSubmatch(diff) + if len(match) <= 1 { + return "" + } + return match[1] +} + +func GetHunksFromDiff(diff string) []*PatchHunk { + headers := hunkHeaderRegexp.FindAllString(diff, -1) + bodies := hunkHeaderRegexp.Split(diff, -1)[1:] // discarding top bit + + headerFirstLineIndices := []int{} + for lineIdx, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "@@ -") { + headerFirstLineIndices = append(headerFirstLineIndices, lineIdx) + } + } + + hunks := make([]*PatchHunk, len(headers)) + for index, header := range headers { + hunks[index] = newHunk(header, bodies[index], headerFirstLineIndices[index]) + } + + return hunks +} + +type PatchModifier struct { + Log *logrus.Entry + filename string + hunks []*PatchHunk + header string +} + +func NewPatchModifier(log *logrus.Entry, filename string, diffText string) *PatchModifier { + return &PatchModifier{ + Log: log, + filename: filename, + hunks: GetHunksFromDiff(diffText), + header: GetHeaderFromDiff(diffText), + } +} + +func (d *PatchModifier) ModifiedPatchForLines(lineIndices []int, reverse bool, keepOriginalHeader bool) string { + // step one is getting only those hunks which we care about + hunksInRange := []*PatchHunk{} +outer: + for _, hunk := range d.hunks { + // if there is any line in our lineIndices array that the hunk contains, we append it + for _, lineIdx := range lineIndices { + if lineIdx >= hunk.FirstLineIdx && lineIdx <= hunk.LastLineIdx { + hunksInRange = append(hunksInRange, hunk) + continue outer + } + } + } + + // step 2 is collecting all the hunks with new headers + startOffset := 0 + formattedHunks := "" + var formattedHunk string + for _, hunk := range hunksInRange { + startOffset, formattedHunk = hunk.formatWithChanges(lineIndices, reverse, startOffset) + formattedHunks += formattedHunk + } + + if formattedHunks == "" { + return "" + } + + var fileHeader string + // for staging/unstaging lines we don't want the original header because + // it makes git confused e.g. when dealing with deleted/added files + // but with building and applying patches the original header gives git + // information it needs to cleanly apply patches + if keepOriginalHeader { + fileHeader = d.header + } else { + fileHeader = fmt.Sprintf("--- a/%s\n+++ b/%s\n", d.filename, d.filename) + } + + return fileHeader + formattedHunks +} + +func (d *PatchModifier) ModifiedPatchForRange(firstLineIdx int, lastLineIdx int, reverse bool, keepOriginalHeader bool) string { + // generate array of consecutive line indices from our range + selectedLines := []int{} + for i := firstLineIdx; i <= lastLineIdx; i++ { + selectedLines = append(selectedLines, i) + } + return d.ModifiedPatchForLines(selectedLines, reverse, keepOriginalHeader) +} + +func (d *PatchModifier) OriginalPatchLength() int { + if len(d.hunks) == 0 { + return 0 + } + + return d.hunks[len(d.hunks)-1].LastLineIdx +} + +func ModifiedPatchForRange(log *logrus.Entry, filename string, diffText string, firstLineIdx int, lastLineIdx int, reverse bool, keepOriginalHeader bool) string { + p := NewPatchModifier(log, filename, diffText) + return p.ModifiedPatchForRange(firstLineIdx, lastLineIdx, reverse, keepOriginalHeader) +} diff --git a/pkg/commands/patch_modifier_test.go b/pkg/commands/patch_modifier_test.go new file mode 100644 index 000000000..87f1000a9 --- /dev/null +++ b/pkg/commands/patch_modifier_test.go @@ -0,0 +1,511 @@ +package commands + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +const simpleDiff = `diff --git a/filename b/filename +index dcd3485..1ba5540 100644 +--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-orange ++grape + ... + ... + ... +` + +const addNewlineToEndOfFile = `diff --git a/filename b/filename +index 80a73f1..e48a11c 100644 +--- a/filename ++++ b/filename +@@ -60,4 +60,4 @@ grape + ... + ... + ... +-last line +\ No newline at end of file ++last line +` + +const removeNewlinefromEndOfFile = `diff --git a/filename b/filename +index e48a11c..80a73f1 100644 +--- a/filename ++++ b/filename +@@ -60,4 +60,4 @@ grape + ... + ... + ... +-last line ++last line +\ No newline at end of file +` + +const twoHunks = `diff --git a/filename b/filename +index e48a11c..b2ab81b 100644 +--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-grape ++orange + ... + ... + ... +@@ -8,6 +8,8 @@ grape + ... + ... + ... ++pear ++lemon + ... + ... + ... +` + +const newFile = `diff --git a/newfile b/newfile +new file mode 100644 +index 0000000..4e680cc +--- /dev/null ++++ b/newfile +@@ -0,0 +1,3 @@ ++apple ++orange ++grape +` + +const addNewlineToPreviouslyEmptyFile = `diff --git a/newfile b/newfile +index e69de29..c6568ea 100644 +--- a/newfile ++++ b/newfile +@@ -0,0 +1 @@ ++new line +\ No newline at end of file +` + +// TestModifyPatchForRange is a function. +func TestModifyPatchForRange(t *testing.T) { + type scenario struct { + testName string + filename string + diffText string + firstLineIndex int + lastLineIndex int + reverse bool + expected string + } + + scenarios := []scenario{ + { + testName: "nothing selected", + filename: "filename", + firstLineIndex: -1, + lastLineIndex: -1, + reverse: false, + diffText: simpleDiff, + expected: "", + }, + { + testName: "only context selected", + filename: "filename", + firstLineIndex: 5, + lastLineIndex: 5, + reverse: false, + diffText: simpleDiff, + expected: "", + }, + { + testName: "whole range selected", + filename: "filename", + firstLineIndex: 0, + lastLineIndex: 11, + reverse: false, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-orange ++grape + ... + ... + ... +`, + }, + { + testName: "only removal selected", + filename: "filename", + firstLineIndex: 6, + lastLineIndex: 6, + reverse: false, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,4 @@ + apple +-orange + ... + ... + ... +`, + }, + { + testName: "only addition selected", + filename: "filename", + firstLineIndex: 7, + lastLineIndex: 7, + reverse: false, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,6 @@ + apple + orange ++grape + ... + ... + ... +`, + }, + { + testName: "range that extends beyond diff bounds", + filename: "filename", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-orange ++grape + ... + ... + ... +`, + }, + { + testName: "whole range reversed", + filename: "filename", + firstLineIndex: 0, + lastLineIndex: 11, + reverse: true, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple ++orange +-grape + ... + ... + ... +`, + }, + { + testName: "removal reversed", + filename: "filename", + firstLineIndex: 6, + lastLineIndex: 6, + reverse: true, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,6 @@ + apple ++orange + grape + ... + ... + ... +`, + }, + { + testName: "removal reversed", + filename: "filename", + firstLineIndex: 7, + lastLineIndex: 7, + reverse: true, + diffText: simpleDiff, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,4 @@ + apple +-grape + ... + ... + ... +`, + }, + { + testName: "add newline to end of file", + filename: "filename", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: addNewlineToEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,4 @@ grape + ... + ... + ... +-last line +\ No newline at end of file ++last line +`, + }, + { + testName: "add newline to end of file, addition only", + filename: "filename", + firstLineIndex: 8, + lastLineIndex: 8, + reverse: true, + diffText: addNewlineToEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,5 @@ grape + ... + ... + ... ++last line +\ No newline at end of file + last line +`, + }, + { + testName: "add newline to end of file, removal only", + filename: "filename", + firstLineIndex: 10, + lastLineIndex: 10, + reverse: true, + diffText: addNewlineToEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,3 @@ grape + ... + ... + ... +-last line +`, + }, + { + testName: "remove newline from end of file", + filename: "filename", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: removeNewlinefromEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,4 @@ grape + ... + ... + ... +-last line ++last line +\ No newline at end of file +`, + }, + { + testName: "remove newline from end of file, removal only", + filename: "filename", + firstLineIndex: 8, + lastLineIndex: 8, + reverse: false, + diffText: removeNewlinefromEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,3 @@ grape + ... + ... + ... +-last line +`, + }, + { + testName: "remove newline from end of file, addition only", + filename: "filename", + firstLineIndex: 9, + lastLineIndex: 9, + reverse: false, + diffText: removeNewlinefromEndOfFile, + expected: `--- a/filename ++++ b/filename +@@ -60,4 +60,5 @@ grape + ... + ... + ... + last line ++last line +\ No newline at end of file +`, + }, + { + testName: "staging two whole hunks", + filename: "filename", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: twoHunks, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-grape ++orange + ... + ... + ... +@@ -8,6 +8,8 @@ grape + ... + ... + ... ++pear ++lemon + ... + ... + ... +`, + }, + { + testName: "staging part of both hunks", + filename: "filename", + firstLineIndex: 7, + lastLineIndex: 15, + reverse: false, + diffText: twoHunks, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,6 @@ + apple + grape ++orange + ... + ... + ... +@@ -8,6 +9,7 @@ grape + ... + ... + ... ++pear + ... + ... + ... +`, + }, + { + testName: "staging part of both hunks, reversed", + filename: "filename", + firstLineIndex: 7, + lastLineIndex: 15, + reverse: true, + diffText: twoHunks, + expected: `--- a/filename ++++ b/filename +@@ -1,5 +1,4 @@ + apple +-orange + ... + ... + ... +@@ -8,8 +7,7 @@ grape + ... + ... + ... +-pear + lemon + ... + ... + ... +`, + }, + { + testName: "adding a new file", + filename: "newfile", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: newFile, + expected: `--- a/newfile ++++ b/newfile +@@ -0,0 +1,3 @@ ++apple ++orange ++grape +`, + }, + { + testName: "adding part of a new file", + filename: "newfile", + firstLineIndex: 6, + lastLineIndex: 7, + reverse: false, + diffText: newFile, + expected: `--- a/newfile ++++ b/newfile +@@ -0,0 +1,2 @@ ++apple ++orange +`, + }, + { + testName: "adding a new file, reversed", + filename: "newfile", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: true, + diffText: newFile, + expected: `--- a/newfile ++++ b/newfile +@@ -1,3 +0,0 @@ +-apple +-orange +-grape +`, + }, + { + testName: "adding a new line to a previously empty file", + filename: "newfile", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: false, + diffText: addNewlineToPreviouslyEmptyFile, + expected: `--- a/newfile ++++ b/newfile +@@ -0,0 +1,1 @@ ++new line +\ No newline at end of file +`, + }, + { + testName: "adding a new line to a previously empty file, reversed", + filename: "newfile", + firstLineIndex: -100, + lastLineIndex: 100, + reverse: true, + diffText: addNewlineToPreviouslyEmptyFile, + expected: `--- a/newfile ++++ b/newfile +@@ -1,1 +0,0 @@ +-new line +\ No newline at end of file +`, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + result := ModifiedPatchForRange(nil, s.filename, s.diffText, s.firstLineIndex, s.lastLineIndex, s.reverse, true) + if !assert.Equal(t, s.expected, result) { + fmt.Println(result) + } + }) + } +} diff --git a/pkg/commands/patch_parser.go b/pkg/commands/patch_parser.go new file mode 100644 index 000000000..06781c8cf --- /dev/null +++ b/pkg/commands/patch_parser.go @@ -0,0 +1,209 @@ +package commands + +import ( + "regexp" + "strings" + + "github.com/fatih/color" + "github.com/jesseduffield/lazygit/pkg/theme" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/sirupsen/logrus" +) + +const ( + PATCH_HEADER = iota + COMMIT_SHA + COMMIT_DESCRIPTION + HUNK_HEADER + ADDITION + DELETION + CONTEXT + NEWLINE_MESSAGE +) + +// the job of this file is to parse a diff, find out where the hunks begin and end, which lines are stageable, and how to find the next hunk from the current position or the next stageable line from the current position. + +type PatchLine struct { + Kind int + Content string // something like '+ hello' (note the first character is not removed) +} + +type PatchParser struct { + Log *logrus.Entry + PatchLines []*PatchLine + PatchHunks []*PatchHunk + HunkStarts []int + StageableLines []int // rename to mention we're talking about indexes +} + +// NewPatchParser builds a new branch list builder +func NewPatchParser(log *logrus.Entry, patch string) (*PatchParser, error) { + hunkStarts, stageableLines, patchLines, err := parsePatch(patch) + if err != nil { + return nil, err + } + + patchHunks := GetHunksFromDiff(patch) + + return &PatchParser{ + Log: log, + HunkStarts: hunkStarts, // deprecated + StageableLines: stageableLines, + PatchLines: patchLines, + PatchHunks: patchHunks, + }, nil +} + +// GetHunkContainingLine takes a line index and an offset and finds the hunk +// which contains the line index, then returns the hunk considering the offset. +// e.g. if the offset is 1 it will return the next hunk. +func (p *PatchParser) GetHunkContainingLine(lineIndex int, offset int) *PatchHunk { + if len(p.PatchHunks) == 0 { + return nil + } + + for index, hunk := range p.PatchHunks { + if lineIndex >= hunk.FirstLineIdx && lineIndex <= hunk.LastLineIdx { + resultIndex := index + offset + if resultIndex < 0 { + resultIndex = 0 + } else if resultIndex > len(p.PatchHunks)-1 { + resultIndex = len(p.PatchHunks) - 1 + } + return p.PatchHunks[resultIndex] + } + } + + // if your cursor is past the last hunk, select the last hunk + if lineIndex > p.PatchHunks[len(p.PatchHunks)-1].LastLineIdx { + return p.PatchHunks[len(p.PatchHunks)-1] + } + + // otherwise select the first + return p.PatchHunks[0] +} + +// selected means you've got it highlighted with your cursor +// included means the line has been included in the patch (only applicable when +// building a patch) +func (l *PatchLine) render(selected bool, included bool) string { + content := l.Content + if len(content) == 0 { + content = " " // using the space so that we can still highlight if necessary + } + + // for hunk headers we need to start off cyan and then use white for the message + if l.Kind == HUNK_HEADER { + re := regexp.MustCompile("(@@.*?@@)(.*)") + match := re.FindStringSubmatch(content) + return coloredString(color.FgCyan, match[1], selected, included) + coloredString(theme.DefaultTextColor, match[2], selected, false) + } + + var colorAttr color.Attribute + switch l.Kind { + case PATCH_HEADER: + colorAttr = color.Bold + case ADDITION: + colorAttr = color.FgGreen + case DELETION: + colorAttr = color.FgRed + case COMMIT_SHA: + colorAttr = color.FgYellow + default: + colorAttr = theme.DefaultTextColor + } + + return coloredString(colorAttr, content, selected, included) +} + +func coloredString(colorAttr color.Attribute, str string, selected bool, included bool) string { + var cl *color.Color + attributes := []color.Attribute{colorAttr} + if selected { + attributes = append(attributes, color.BgBlue) + } + cl = color.New(attributes...) + var clIncluded *color.Color + if included { + clIncluded = color.New(append(attributes, color.BgGreen)...) + } else { + clIncluded = color.New(attributes...) + } + + if len(str) < 2 { + return utils.ColoredStringDirect(str, clIncluded) + } + + return utils.ColoredStringDirect(str[:1], clIncluded) + utils.ColoredStringDirect(str[1:], cl) +} + +func parsePatch(patch string) ([]int, []int, []*PatchLine, error) { + lines := strings.Split(patch, "\n") + hunkStarts := []int{} + stageableLines := []int{} + pastFirstHunkHeader := false + pastCommitDescription := true + patchLines := make([]*PatchLine, len(lines)) + var lineKind int + var firstChar string + for index, line := range lines { + firstChar = " " + if len(line) > 0 { + firstChar = line[:1] + } + if index == 0 && strings.HasPrefix(line, "commit") { + lineKind = COMMIT_SHA + pastCommitDescription = false + } else if !pastCommitDescription { + if strings.HasPrefix(line, "diff") || strings.HasPrefix(line, "---") { + pastCommitDescription = true + lineKind = PATCH_HEADER + } else { + lineKind = COMMIT_DESCRIPTION + } + } else if firstChar == "@" { + pastFirstHunkHeader = true + hunkStarts = append(hunkStarts, index) + lineKind = HUNK_HEADER + } else if pastFirstHunkHeader { + switch firstChar { + case "-": + lineKind = DELETION + stageableLines = append(stageableLines, index) + case "+": + lineKind = ADDITION + stageableLines = append(stageableLines, index) + case "\\": + lineKind = NEWLINE_MESSAGE + case " ": + lineKind = CONTEXT + } + } else { + lineKind = PATCH_HEADER + } + patchLines[index] = &PatchLine{Kind: lineKind, Content: line} + } + return hunkStarts, stageableLines, patchLines, nil +} + +// Render returns the coloured string of the diff with any selected lines highlighted +func (p *PatchParser) Render(firstLineIndex int, lastLineIndex int, incLineIndices []int) string { + renderedLines := make([]string, len(p.PatchLines)) + for index, patchLine := range p.PatchLines { + selected := index >= firstLineIndex && index <= lastLineIndex + included := utils.IncludesInt(incLineIndices, index) + renderedLines[index] = patchLine.render(selected, included) + } + return strings.Join(renderedLines, "\n") +} + +// GetNextStageableLineIndex takes a line index and returns the line index of the next stageable line +// note this will actually include the