diff --git a/pkg/commands/commit.go b/pkg/commands/commit.go index 54e94ef60..4c304271d 100644 --- a/pkg/commands/commit.go +++ b/pkg/commands/commit.go @@ -13,6 +13,7 @@ type Commit struct { DisplayString string } +// GetDisplayStrings is a function. func (c *Commit) GetDisplayStrings() []string { red := color.New(color.FgRed) yellow := color.New(color.FgGreen) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 2d2710a42..584558d7e 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -267,6 +267,7 @@ func (c *GitCommand) NewBranch(name string) error { return c.OSCommand.RunCommand(fmt.Sprintf("git checkout -b %s", name)) } +// CurrentBranchName is a function. func (c *GitCommand) CurrentBranchName() (string, error) { branchName, err := c.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD") if err != nil { @@ -490,7 +491,6 @@ func (c *GitCommand) getMergeBase() (string, error) { output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("git merge-base HEAD %s", baseBranch)) if err != nil { // swallowing error because it's not a big deal; probably because there are no commits yet - c.Log.Error(err) } return output, nil } @@ -576,9 +576,10 @@ func (c *GitCommand) CheckRemoteBranchExists(branch *Branch) bool { } // Diff returns the diff of a file -func (c *GitCommand) Diff(file *File) string { +func (c *GitCommand) Diff(file *File, plain bool) string { cachedArg := "" trackedArg := "--" + colorArg := "--color" fileName := c.OSCommand.Quote(file.Name) if file.HasStagedChanges && !file.HasUnstagedChanges { cachedArg = "--cached" @@ -586,9 +587,25 @@ func (c *GitCommand) Diff(file *File) string { if !file.Tracked && !file.HasStagedChanges { trackedArg = "--no-index /dev/null" } - command := fmt.Sprintf("git diff --color %s %s %s", cachedArg, trackedArg, fileName) + if plain { + colorArg = "" + } + + command := fmt.Sprintf("git diff %s %s %s %s", colorArg, cachedArg, trackedArg, fileName) // for now we assume an error means the file was deleted s, _ := c.OSCommand.RunCommandWithOutput(command) return s } + +func (c *GitCommand) ApplyPatch(patch string) (string, error) { + filename, err := c.OSCommand.CreateTempFile("patch", patch) + if err != nil { + c.Log.Error(err) + return "", err + } + + defer func() { _ = c.OSCommand.RemoveFile(filename) }() + + return c.OSCommand.RunCommandWithOutput(fmt.Sprintf("git apply --cached %s", filename)) +} diff --git a/pkg/commands/git_test.go b/pkg/commands/git_test.go index 0410ceb2c..da6a90c98 100644 --- a/pkg/commands/git_test.go +++ b/pkg/commands/git_test.go @@ -23,26 +23,32 @@ type fileInfoMock struct { sys interface{} } +// Name is a function. func (f fileInfoMock) Name() string { return f.name } +// Size is a function. func (f fileInfoMock) Size() int64 { return f.size } +// Mode is a function. func (f fileInfoMock) Mode() os.FileMode { return f.fileMode } +// ModTime is a function. func (f fileInfoMock) ModTime() time.Time { return f.fileModTime } +// IsDir is a function. func (f fileInfoMock) IsDir() bool { return f.isDir } +// Sys is a function. func (f fileInfoMock) Sys() interface{} { return f.sys } @@ -64,6 +70,7 @@ func newDummyGitCommand() *GitCommand { } } +// TestVerifyInGitRepo is a function. func TestVerifyInGitRepo(t *testing.T) { type scenario struct { testName string @@ -100,6 +107,7 @@ func TestVerifyInGitRepo(t *testing.T) { } } +// TestNavigateToRepoRootDirectory is a function. func TestNavigateToRepoRootDirectory(t *testing.T) { type scenario struct { testName string @@ -156,6 +164,7 @@ func TestNavigateToRepoRootDirectory(t *testing.T) { } } +// TestSetupRepositoryAndWorktree is a function. func TestSetupRepositoryAndWorktree(t *testing.T) { type scenario struct { testName string @@ -224,6 +233,7 @@ func TestSetupRepositoryAndWorktree(t *testing.T) { } } +// TestNewGitCommand is a function. func TestNewGitCommand(t *testing.T) { actual, err := os.Getwd() assert.NoError(t, err) @@ -271,6 +281,7 @@ func TestNewGitCommand(t *testing.T) { } } +// TestGitCommandGetStashEntries is a function. func TestGitCommandGetStashEntries(t *testing.T) { type scenario struct { testName string @@ -323,6 +334,7 @@ func TestGitCommandGetStashEntries(t *testing.T) { } } +// TestGitCommandGetStashEntryDiff is a function. func TestGitCommandGetStashEntryDiff(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -337,6 +349,7 @@ func TestGitCommandGetStashEntryDiff(t *testing.T) { assert.NoError(t, err) } +// TestGitCommandGetStatusFiles is a function. func TestGitCommandGetStatusFiles(t *testing.T) { type scenario struct { testName string @@ -423,6 +436,7 @@ func TestGitCommandGetStatusFiles(t *testing.T) { } } +// TestGitCommandStashDo is a function. func TestGitCommandStashDo(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -435,6 +449,7 @@ func TestGitCommandStashDo(t *testing.T) { assert.NoError(t, gitCmd.StashDo(1, "drop")) } +// TestGitCommandStashSave is a function. func TestGitCommandStashSave(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -447,6 +462,7 @@ func TestGitCommandStashSave(t *testing.T) { assert.NoError(t, gitCmd.StashSave("A stash message")) } +// TestGitCommandCommitAmend is a function. func TestGitCommandCommitAmend(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -460,6 +476,7 @@ func TestGitCommandCommitAmend(t *testing.T) { assert.NoError(t, err) } +// TestGitCommandMergeStatusFiles is a function. func TestGitCommandMergeStatusFiles(t *testing.T) { type scenario struct { testName string @@ -540,6 +557,7 @@ func TestGitCommandMergeStatusFiles(t *testing.T) { } } +// TestGitCommandUpstreamDifferentCount is a function. func TestGitCommandUpstreamDifferentCount(t *testing.T) { type scenario struct { testName string @@ -597,6 +615,7 @@ func TestGitCommandUpstreamDifferentCount(t *testing.T) { } } +// TestGitCommandGetCommitsToPush is a function. func TestGitCommandGetCommitsToPush(t *testing.T) { type scenario struct { testName string @@ -635,6 +654,7 @@ func TestGitCommandGetCommitsToPush(t *testing.T) { } } +// TestGitCommandRenameCommit is a function. func TestGitCommandRenameCommit(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -647,6 +667,7 @@ func TestGitCommandRenameCommit(t *testing.T) { assert.NoError(t, gitCmd.RenameCommit("test")) } +// TestGitCommandResetToCommit is a function. func TestGitCommandResetToCommit(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -659,6 +680,7 @@ func TestGitCommandResetToCommit(t *testing.T) { assert.NoError(t, gitCmd.ResetToCommit("78976bc")) } +// TestGitCommandNewBranch is a function. func TestGitCommandNewBranch(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -671,6 +693,7 @@ func TestGitCommandNewBranch(t *testing.T) { assert.NoError(t, gitCmd.NewBranch("test")) } +// TestGitCommandDeleteBranch is a function. func TestGitCommandDeleteBranch(t *testing.T) { type scenario struct { testName string @@ -720,6 +743,7 @@ func TestGitCommandDeleteBranch(t *testing.T) { } } +// TestGitCommandMerge is a function. func TestGitCommandMerge(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -732,6 +756,7 @@ func TestGitCommandMerge(t *testing.T) { assert.NoError(t, gitCmd.Merge("test")) } +// TestGitCommandUsingGpg is a function. func TestGitCommandUsingGpg(t *testing.T) { type scenario struct { testName string @@ -825,6 +850,7 @@ func TestGitCommandUsingGpg(t *testing.T) { } } +// TestGitCommandCommit is a function. func TestGitCommandCommit(t *testing.T) { type scenario struct { testName string @@ -894,6 +920,7 @@ func TestGitCommandCommit(t *testing.T) { } } +// TestGitCommandCommitAmendFromFiles is a function. func TestGitCommandCommitAmendFromFiles(t *testing.T) { type scenario struct { testName string @@ -963,6 +990,7 @@ func TestGitCommandCommitAmendFromFiles(t *testing.T) { } } +// TestGitCommandPush is a function. func TestGitCommandPush(t *testing.T) { type scenario struct { testName string @@ -1025,6 +1053,7 @@ func TestGitCommandPush(t *testing.T) { } } +// TestGitCommandSquashPreviousTwoCommits is a function. func TestGitCommandSquashPreviousTwoCommits(t *testing.T) { type scenario struct { testName string @@ -1088,6 +1117,7 @@ func TestGitCommandSquashPreviousTwoCommits(t *testing.T) { } } +// TestGitCommandSquashFixupCommit is a function. func TestGitCommandSquashFixupCommit(t *testing.T) { type scenario struct { testName string @@ -1151,6 +1181,7 @@ func TestGitCommandSquashFixupCommit(t *testing.T) { } } +// TestGitCommandCatFile is a function. func TestGitCommandCatFile(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -1165,6 +1196,7 @@ func TestGitCommandCatFile(t *testing.T) { assert.Equal(t, "test", o) } +// TestGitCommandStageFile is a function. func TestGitCommandStageFile(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -1177,6 +1209,7 @@ func TestGitCommandStageFile(t *testing.T) { assert.NoError(t, gitCmd.StageFile("test.txt")) } +// TestGitCommandUnstageFile is a function. func TestGitCommandUnstageFile(t *testing.T) { type scenario struct { testName string @@ -1223,6 +1256,7 @@ func TestGitCommandUnstageFile(t *testing.T) { } } +// TestGitCommandIsInMergeState is a function. func TestGitCommandIsInMergeState(t *testing.T) { type scenario struct { testName string @@ -1291,6 +1325,7 @@ func TestGitCommandIsInMergeState(t *testing.T) { } } +// TestGitCommandRemoveFile is a function. func TestGitCommandRemoveFile(t *testing.T) { type scenario struct { testName string @@ -1492,6 +1527,7 @@ func TestGitCommandRemoveFile(t *testing.T) { } } +// TestGitCommandShow is a function. func TestGitCommandShow(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -1505,6 +1541,7 @@ func TestGitCommandShow(t *testing.T) { assert.NoError(t, err) } +// TestGitCommandCheckout is a function. func TestGitCommandCheckout(t *testing.T) { type scenario struct { testName string @@ -1551,6 +1588,7 @@ func TestGitCommandCheckout(t *testing.T) { } } +// TestGitCommandGetBranchGraph is a function. func TestGitCommandGetBranchGraph(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = func(cmd string, args ...string) *exec.Cmd { @@ -1564,6 +1602,7 @@ func TestGitCommandGetBranchGraph(t *testing.T) { assert.NoError(t, err) } +// TestGitCommandGetCommits is a function. func TestGitCommandGetCommits(t *testing.T) { type scenario struct { testName string @@ -1685,6 +1724,7 @@ func TestGitCommandGetCommits(t *testing.T) { } } +// TestGitCommandGetLog is a function. func TestGitCommandGetLog(t *testing.T) { type scenario struct { testName string @@ -1727,11 +1767,13 @@ func TestGitCommandGetLog(t *testing.T) { } } +// TestGitCommandDiff is a function. func TestGitCommandDiff(t *testing.T) { type scenario struct { testName string command func(string, ...string) *exec.Cmd file *File + plain bool } scenarios := []scenario{ @@ -1748,6 +1790,22 @@ func TestGitCommandDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, + false, + }, + { + "Default case", + func(cmd string, args ...string) *exec.Cmd { + assert.EqualValues(t, "git", cmd) + assert.EqualValues(t, []string{"diff", "--", "test.txt"}, args) + + return exec.Command("echo") + }, + &File{ + Name: "test.txt", + HasStagedChanges: false, + Tracked: true, + }, + true, }, { "All changes staged", @@ -1763,6 +1821,7 @@ func TestGitCommandDiff(t *testing.T) { HasUnstagedChanges: false, Tracked: true, }, + false, }, { "File not tracked and file has no staged changes", @@ -1777,6 +1836,7 @@ func TestGitCommandDiff(t *testing.T) { HasStagedChanges: false, Tracked: false, }, + false, }, } @@ -1784,11 +1844,12 @@ func TestGitCommandDiff(t *testing.T) { t.Run(s.testName, func(t *testing.T) { gitCmd := newDummyGitCommand() gitCmd.OSCommand.command = s.command - gitCmd.Diff(s.file) + gitCmd.Diff(s.file, s.plain) }) } } +// TestGitCommandGetMergeBase is a function. func TestGitCommandGetMergeBase(t *testing.T) { type scenario struct { testName string @@ -1878,6 +1939,7 @@ func TestGitCommandGetMergeBase(t *testing.T) { } } +// TestGitCommandCurrentBranchName is a function. func TestGitCommandCurrentBranchName(t *testing.T) { type scenario struct { testName string @@ -1939,3 +2001,61 @@ func TestGitCommandCurrentBranchName(t *testing.T) { }) } } + +func TestGitCommandApplyPatch(t *testing.T) { + type scenario struct { + testName string + command func(string, ...string) *exec.Cmd + test func(string, error) + } + + scenarios := []scenario{ + { + "valid case", + func(cmd string, args ...string) *exec.Cmd { + assert.Equal(t, "git", cmd) + assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2]) + filename := args[2] + content, err := ioutil.ReadFile(filename) + assert.NoError(t, err) + + assert.Equal(t, "test", string(content)) + + return exec.Command("echo", "done") + }, + func(output string, err error) { + assert.NoError(t, err) + assert.EqualValues(t, "done\n", output) + }, + }, + { + "command returns error", + func(cmd string, args ...string) *exec.Cmd { + assert.Equal(t, "git", cmd) + assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2]) + filename := args[2] + // TODO: Ideally we want to mock out OSCommand here so that we're not + // double handling testing it's CreateTempFile functionality, + // but it is going to take a bit of work to make a proper mock for it + // so I'm leaving it for another PR + content, err := ioutil.ReadFile(filename) + assert.NoError(t, err) + + assert.Equal(t, "test", string(content)) + + return exec.Command("test") + }, + func(output string, err error) { + assert.Error(t, err) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + gitCmd := newDummyGitCommand() + gitCmd.OSCommand.command = s.command + s.test(gitCmd.ApplyPatch("test")) + }) + } +} diff --git a/pkg/commands/os.go b/pkg/commands/os.go index f64f0732e..faf6c5aec 100644 --- a/pkg/commands/os.go +++ b/pkg/commands/os.go @@ -2,6 +2,7 @@ package commands import ( "errors" + "io/ioutil" "os" "os/exec" "regexp" @@ -157,7 +158,7 @@ func (c *OSCommand) OpenFile(filename string) error { return err } -// OpenFile opens a file with the given +// OpenLink opens a file with the given func (c *OSCommand) OpenLink(link string) error { commandTemplate := c.Config.GetUserConfig().GetString("os.openLinkCommand") templateValues := map[string]string{ @@ -224,3 +225,28 @@ func (c *OSCommand) AppendLineToFile(filename, line string) error { _, err = f.WriteString("\n" + line) return err } + +// CreateTempFile writes a string to a new temp file and returns the file's name +func (c *OSCommand) CreateTempFile(filename, content string) (string, error) { + tmpfile, err := ioutil.TempFile("", filename) + if err != nil { + c.Log.Error(err) + return "", err + } + + if _, err := tmpfile.Write([]byte(content)); err != nil { + c.Log.Error(err) + return "", err + } + if err := tmpfile.Close(); err != nil { + c.Log.Error(err) + return "", err + } + + return tmpfile.Name(), nil +} + +// RemoveFile removes a file at the specified path +func (c *OSCommand) RemoveFile(filename string) error { + return os.Remove(filename) +} diff --git a/pkg/commands/os_test.go b/pkg/commands/os_test.go index aeef4a6e5..a08c4b57d 100644 --- a/pkg/commands/os_test.go +++ b/pkg/commands/os_test.go @@ -1,6 +1,7 @@ package commands import ( + "io/ioutil" "os" "os/exec" "testing" @@ -29,6 +30,7 @@ func newDummyAppConfig() *config.AppConfig { return appConfig } +// TestOSCommandRunCommandWithOutput is a function. func TestOSCommandRunCommandWithOutput(t *testing.T) { type scenario struct { command string @@ -56,6 +58,7 @@ func TestOSCommandRunCommandWithOutput(t *testing.T) { } } +// TestOSCommandRunCommand is a function. func TestOSCommandRunCommand(t *testing.T) { type scenario struct { command string @@ -76,6 +79,7 @@ func TestOSCommandRunCommand(t *testing.T) { } } +// TestOSCommandOpenFile is a function. func TestOSCommandOpenFile(t *testing.T) { type scenario struct { filename string @@ -126,6 +130,7 @@ func TestOSCommandOpenFile(t *testing.T) { } } +// TestOSCommandEditFile is a function. func TestOSCommandEditFile(t *testing.T) { type scenario struct { filename string @@ -255,6 +260,7 @@ func TestOSCommandEditFile(t *testing.T) { } } +// TestOSCommandQuote is a function. func TestOSCommandQuote(t *testing.T) { osCommand := newDummyOSCommand() @@ -278,7 +284,7 @@ func TestOSCommandQuoteSingleQuote(t *testing.T) { assert.EqualValues(t, expected, actual) } -// TestOSCommandQuoteSingleQuote tests the quote function with " quotes explicitly for Linux +// TestOSCommandQuoteDoubleQuote tests the quote function with " quotes explicitly for Linux func TestOSCommandQuoteDoubleQuote(t *testing.T) { osCommand := newDummyOSCommand() @@ -291,6 +297,7 @@ func TestOSCommandQuoteDoubleQuote(t *testing.T) { assert.EqualValues(t, expected, actual) } +// TestOSCommandUnquote is a function. func TestOSCommandUnquote(t *testing.T) { osCommand := newDummyOSCommand() @@ -301,6 +308,7 @@ func TestOSCommandUnquote(t *testing.T) { assert.EqualValues(t, expected, actual) } +// TestOSCommandFileType is a function. func TestOSCommandFileType(t *testing.T) { type scenario struct { path string @@ -357,3 +365,34 @@ func TestOSCommandFileType(t *testing.T) { _ = os.RemoveAll(s.path) } } + +func TestOSCommandCreateTempFile(t *testing.T) { + type scenario struct { + testName string + filename string + content string + test func(string, error) + } + + scenarios := []scenario{ + { + "valid case", + "filename", + "content", + func(path string, err error) { + assert.NoError(t, err) + + content, err := ioutil.ReadFile(path) + assert.NoError(t, err) + + assert.Equal(t, "content", string(content)) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + s.test(newDummyOSCommand().CreateTempFile(s.filename, s.content)) + }) + } +} diff --git a/pkg/commands/pull_request_test.go b/pkg/commands/pull_request_test.go index a551ee081..774baf92e 100644 --- a/pkg/commands/pull_request_test.go +++ b/pkg/commands/pull_request_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" ) +// TestGetRepoInfoFromURL is a function. func TestGetRepoInfoFromURL(t *testing.T) { type scenario struct { testName string @@ -41,6 +42,7 @@ func TestGetRepoInfoFromURL(t *testing.T) { } } +// TestCreatePullRequest is a function. func TestCreatePullRequest(t *testing.T) { type scenario struct { testName string diff --git a/pkg/git/patch_modifier.go b/pkg/git/patch_modifier.go new file mode 100644 index 000000000..3c523232e --- /dev/null +++ b/pkg/git/patch_modifier.go @@ -0,0 +1,156 @@ +package git + +import ( + "errors" + "regexp" + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/sirupsen/logrus" +) + +type PatchModifier struct { + Log *logrus.Entry + Tr *i18n.Localizer +} + +// NewPatchModifier builds a new branch list builder +func NewPatchModifier(log *logrus.Entry) (*PatchModifier, error) { + return &PatchModifier{ + Log: log, + }, nil +} + +// ModifyPatchForHunk takes the original patch, which may contain several hunks, +// and removes any hunks that aren't the selected hunk +func (p *PatchModifier) ModifyPatchForHunk(patch string, hunkStarts []int, currentLine int) (string, error) { + // get hunk start and end + lines := strings.Split(patch, "\n") + hunkStartIndex := utils.PrevIndex(hunkStarts, currentLine) + hunkStart := hunkStarts[hunkStartIndex] + nextHunkStartIndex := utils.NextIndex(hunkStarts, currentLine) + var hunkEnd int + if nextHunkStartIndex == 0 { + hunkEnd = len(lines) - 1 + } else { + hunkEnd = hunkStarts[nextHunkStartIndex] + } + + headerLength, err := p.getHeaderLength(lines) + if err != nil { + return "", err + } + + output := strings.Join(lines[0:headerLength], "\n") + "\n" + output += strings.Join(lines[hunkStart:hunkEnd], "\n") + "\n" + + return output, nil +} + +func (p *PatchModifier) getHeaderLength(patchLines []string) (int, error) { + for index, line := range patchLines { + if strings.HasPrefix(line, "@@") { + return index, nil + } + } + return 0, errors.New(p.Tr.SLocalize("CantFindHunks")) +} + +// ModifyPatchForLine takes the original patch, which may contain several hunks, +// and the line number of the line we want to stage +func (p *PatchModifier) ModifyPatchForLine(patch string, lineNumber int) (string, error) { + lines := strings.Split(patch, "\n") + headerLength, err := p.getHeaderLength(lines) + if err != nil { + return "", err + } + output := strings.Join(lines[0:headerLength], "\n") + "\n" + + hunkStart, err := p.getHunkStart(lines, lineNumber) + if err != nil { + return "", err + } + + hunk, err := p.getModifiedHunk(lines, hunkStart, lineNumber) + if err != nil { + return "", err + } + + output += strings.Join(hunk, "\n") + + return output, nil +} + +// getHunkStart returns the line number of the hunk we're going to be modifying +// in order to stage our line +func (p *PatchModifier) getHunkStart(patchLines []string, lineNumber int) (int, error) { + // find the hunk that we're modifying + hunkStart := 0 + for index, line := range patchLines { + if strings.HasPrefix(line, "@@") { + hunkStart = index + } + if index == lineNumber { + return hunkStart, nil + } + } + + return 0, errors.New(p.Tr.SLocalize("CantFindHunk")) +} + +func (p *PatchModifier) getModifiedHunk(patchLines []string, hunkStart int, lineNumber int) ([]string, error) { + lineChanges := 0 + // strip the hunk down to just the line we want to stage + newHunk := []string{patchLines[hunkStart]} + for offsetIndex, line := range patchLines[hunkStart+1:] { + index := offsetIndex + hunkStart + 1 + if strings.HasPrefix(line, "@@") { + newHunk = append(newHunk, "\n") + break + } + if index != lineNumber { + // we include other removals but treat them like context + if strings.HasPrefix(line, "-") { + newHunk = append(newHunk, " "+line[1:]) + lineChanges += 1 + continue + } + // we don't include other additions + if strings.HasPrefix(line, "+") { + lineChanges -= 1 + continue + } + } + newHunk = append(newHunk, line) + } + + var err error + newHunk[0], err = p.updatedHeader(newHunk[0], lineChanges) + if err != nil { + return nil, err + } + + return newHunk, nil +} + +// updatedHeader returns the hunk header with the updated line range +// we need to update the hunk length to reflect the changes we made +// if the hunk has three additions but we're only staging one, then +// @@ -14,8 +14,11 @@ import ( +// becomes +// @@ -14,8 +14,9 @@ import ( +func (p *PatchModifier) updatedHeader(currentHeader string, lineChanges int) (string, error) { + // current counter is the number after the second comma + re := regexp.MustCompile(`(\d+) @@`) + prevLengthString := re.FindStringSubmatch(currentHeader)[1] + + prevLength, err := strconv.Atoi(prevLengthString) + if err != nil { + return "", err + } + re = regexp.MustCompile(`\d+ @@`) + newLength := strconv.Itoa(prevLength + lineChanges) + return re.ReplaceAllString(currentHeader, newLength+" @@"), nil +} diff --git a/pkg/git/patch_modifier_test.go b/pkg/git/patch_modifier_test.go new file mode 100644 index 000000000..bc2073d55 --- /dev/null +++ b/pkg/git/patch_modifier_test.go @@ -0,0 +1,89 @@ +package git + +import ( + "io/ioutil" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newDummyLog() *logrus.Entry { + log := logrus.New() + log.Out = ioutil.Discard + return log.WithField("test", "test") +} + +func newDummyPatchModifier() *PatchModifier { + return &PatchModifier{ + Log: newDummyLog(), + } +} +func TestModifyPatchForLine(t *testing.T) { + type scenario struct { + testName string + patchFilename string + lineNumber int + shouldError bool + expectedPatchFilename string + } + + scenarios := []scenario{ + { + "Removing one line", + "testdata/testPatchBefore.diff", + 8, + false, + "testdata/testPatchAfter1.diff", + }, + { + "Adding one line", + "testdata/testPatchBefore.diff", + 10, + false, + "testdata/testPatchAfter2.diff", + }, + { + "Adding one line in top hunk in diff with multiple hunks", + "testdata/testPatchBefore2.diff", + 20, + false, + "testdata/testPatchAfter3.diff", + }, + { + "Adding one line in top hunk in diff with multiple hunks", + "testdata/testPatchBefore2.diff", + 53, + false, + "testdata/testPatchAfter4.diff", + }, + { + "adding unstaged file with a single line", + "testdata/addedFile.diff", + 6, + false, + "testdata/addedFile.diff", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + p := newDummyPatchModifier() + beforePatch, err := ioutil.ReadFile(s.patchFilename) + if err != nil { + panic("Cannot open file at " + s.patchFilename) + } + afterPatch, err := p.ModifyPatchForLine(string(beforePatch), s.lineNumber) + if s.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + expected, err := ioutil.ReadFile(s.expectedPatchFilename) + if err != nil { + panic("Cannot open file at " + s.expectedPatchFilename) + } + assert.Equal(t, string(expected), afterPatch) + } + }) + } +} diff --git a/pkg/git/patch_parser.go b/pkg/git/patch_parser.go new file mode 100644 index 000000000..1dbacd01c --- /dev/null +++ b/pkg/git/patch_parser.go @@ -0,0 +1,36 @@ +package git + +import ( + "strings" + + "github.com/sirupsen/logrus" +) + +type PatchParser struct { + Log *logrus.Entry +} + +// NewPatchParser builds a new branch list builder +func NewPatchParser(log *logrus.Entry) (*PatchParser, error) { + return &PatchParser{ + Log: log, + }, nil +} + +func (p *PatchParser) ParsePatch(patch string) ([]int, []int, error) { + lines := strings.Split(patch, "\n") + hunkStarts := []int{} + stageableLines := []int{} + pastHeader := false + for index, line := range lines { + if strings.HasPrefix(line, "@@") { + pastHeader = true + hunkStarts = append(hunkStarts, index) + } + if pastHeader && (strings.HasPrefix(line, "-") || strings.HasPrefix(line, "+")) { + stageableLines = append(stageableLines, index) + } + } + p.Log.WithField("staging", "staging").Info(stageableLines) + return hunkStarts, stageableLines, nil +} diff --git a/pkg/git/patch_parser_test.go b/pkg/git/patch_parser_test.go new file mode 100644 index 000000000..6670aaea2 --- /dev/null +++ b/pkg/git/patch_parser_test.go @@ -0,0 +1,65 @@ +package git + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" +) + +func newDummyPatchParser() *PatchParser { + return &PatchParser{ + Log: newDummyLog(), + } +} +func TestParsePatch(t *testing.T) { + type scenario struct { + testName string + patchFilename string + shouldError bool + expectedStageableLines []int + expectedHunkStarts []int + } + + scenarios := []scenario{ + { + "Diff with one hunk", + "testdata/testPatchBefore.diff", + false, + []int{8, 9, 10, 11}, + []int{4}, + }, + { + "Diff with two hunks", + "testdata/testPatchBefore2.diff", + false, + []int{8, 9, 10, 11, 12, 13, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 34, 35, 36, 37, 45, 46, 47, 48, 49, 50, 51, 52, 53}, + []int{4, 41}, + }, + { + "Unstaged file", + "testdata/addedFile.diff", + false, + []int{6}, + []int{5}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + p := newDummyPatchParser() + beforePatch, err := ioutil.ReadFile(s.patchFilename) + if err != nil { + panic("Cannot open file at " + s.patchFilename) + } + hunkStarts, stageableLines, err := p.ParsePatch(string(beforePatch)) + if s.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, s.expectedStageableLines, stageableLines) + assert.Equal(t, s.expectedHunkStarts, hunkStarts) + } + }) + } +} diff --git a/pkg/git/testdata/addedFile.diff b/pkg/git/testdata/addedFile.diff new file mode 100644 index 000000000..53966c4a1 --- /dev/null +++ b/pkg/git/testdata/addedFile.diff @@ -0,0 +1,7 @@ +diff --git a/blah b/blah +new file mode 100644 +index 0000000..907b308 +--- /dev/null ++++ b/blah +@@ -0,0 +1 @@ ++blah diff --git a/pkg/git/testdata/testPatchAfter1.diff b/pkg/git/testdata/testPatchAfter1.diff new file mode 100644 index 000000000..88066e1c2 --- /dev/null +++ b/pkg/git/testdata/testPatchAfter1.diff @@ -0,0 +1,13 @@ +diff --git a/pkg/git/branch_list_builder.go b/pkg/git/branch_list_builder.go +index 60ec4e0..db4485d 100644 +--- a/pkg/git/branch_list_builder.go ++++ b/pkg/git/branch_list_builder.go +@@ -14,8 +14,7 @@ import ( + + // 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 diff --git a/pkg/git/testdata/testPatchAfter2.diff b/pkg/git/testdata/testPatchAfter2.diff new file mode 100644 index 000000000..0a17c2b67 --- /dev/null +++ b/pkg/git/testdata/testPatchAfter2.diff @@ -0,0 +1,14 @@ +diff --git a/pkg/git/branch_list_builder.go b/pkg/git/branch_list_builder.go +index 60ec4e0..db4485d 100644 +--- a/pkg/git/branch_list_builder.go ++++ b/pkg/git/branch_list_builder.go +@@ -14,8 +14,9 @@ import ( + + // 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. ++// test 2 - if I remove this, I decrement the end counter + // 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 diff --git a/pkg/git/testdata/testPatchAfter3.diff b/pkg/git/testdata/testPatchAfter3.diff new file mode 100644 index 000000000..03492450d --- /dev/null +++ b/pkg/git/testdata/testPatchAfter3.diff @@ -0,0 +1,25 @@ +diff --git a/pkg/git/patch_modifier.go b/pkg/git/patch_modifier.go +index a8fc600..6d8f7d7 100644 +--- a/pkg/git/patch_modifier.go ++++ b/pkg/git/patch_modifier.go +@@ -36,18 +36,19 @@ func (p *PatchModifier) ModifyPatchForHunk(patch string, hunkStarts []int, curre + hunkEnd = hunkStarts[nextHunkStartIndex] + } + + headerLength := 4 + output := strings.Join(lines[0:headerLength], "\n") + "\n" + output += strings.Join(lines[hunkStart:hunkEnd], "\n") + "\n" + + return output, nil + } + ++func getHeaderLength(patchLines []string) (int, error) { + // ModifyPatchForLine takes the original patch, which may contain several hunks, + // and the line number of the line we want to stage + func (p *PatchModifier) ModifyPatchForLine(patch string, lineNumber int) (string, error) { + lines := strings.Split(patch, "\n") + headerLength := 4 + output := strings.Join(lines[0:headerLength], "\n") + "\n" + + hunkStart, err := p.getHunkStart(lines, lineNumber) + diff --git a/pkg/git/testdata/testPatchAfter4.diff b/pkg/git/testdata/testPatchAfter4.diff new file mode 100644 index 000000000..99f894d9d --- /dev/null +++ b/pkg/git/testdata/testPatchAfter4.diff @@ -0,0 +1,19 @@ +diff --git a/pkg/git/patch_modifier.go b/pkg/git/patch_modifier.go +index a8fc600..6d8f7d7 100644 +--- a/pkg/git/patch_modifier.go ++++ b/pkg/git/patch_modifier.go +@@ -124,13 +140,14 @@ func (p *PatchModifier) getModifiedHunk(patchLines []string, hunkStart int, line + // @@ -14,8 +14,9 @@ import ( + func (p *PatchModifier) updatedHeader(currentHeader string, lineChanges int) (string, error) { + // current counter is the number after the second comma + re := regexp.MustCompile(`^[^,]+,[^,]+,(\d+)`) + matches := re.FindStringSubmatch(currentHeader) + if len(matches) < 2 { + re = regexp.MustCompile(`^[^,]+,[^+]+\+(\d+)`) + matches = re.FindStringSubmatch(currentHeader) + } + prevLengthString := matches[1] ++ prevLengthString := re.FindStringSubmatch(currentHeader)[1] + + prevLength, err := strconv.Atoi(prevLengthString) + if err != nil { diff --git a/pkg/git/testdata/testPatchBefore.diff b/pkg/git/testdata/testPatchBefore.diff new file mode 100644 index 000000000..14e4b0e23 --- /dev/null +++ b/pkg/git/testdata/testPatchBefore.diff @@ -0,0 +1,15 @@ +diff --git a/pkg/git/branch_list_builder.go b/pkg/git/branch_list_builder.go +index 60ec4e0..db4485d 100644 +--- a/pkg/git/branch_list_builder.go ++++ b/pkg/git/branch_list_builder.go +@@ -14,8 +14,8 @@ import ( + + // 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. ++// test 2 - if I remove this, I decrement the end counter ++// test + // 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 diff --git a/pkg/git/testdata/testPatchBefore2.diff b/pkg/git/testdata/testPatchBefore2.diff new file mode 100644 index 000000000..552c04f5e --- /dev/null +++ b/pkg/git/testdata/testPatchBefore2.diff @@ -0,0 +1,57 @@ +diff --git a/pkg/git/patch_modifier.go b/pkg/git/patch_modifier.go +index a8fc600..6d8f7d7 100644 +--- a/pkg/git/patch_modifier.go ++++ b/pkg/git/patch_modifier.go +@@ -36,18 +36,34 @@ func (p *PatchModifier) ModifyPatchForHunk(patch string, hunkStarts []int, curre + hunkEnd = hunkStarts[nextHunkStartIndex] + } + +- headerLength := 4 ++ headerLength, err := getHeaderLength(lines) ++ if err != nil { ++ return "", err ++ } ++ + output := strings.Join(lines[0:headerLength], "\n") + "\n" + output += strings.Join(lines[hunkStart:hunkEnd], "\n") + "\n" + + return output, nil + } + ++func getHeaderLength(patchLines []string) (int, error) { ++ for index, line := range patchLines { ++ if strings.HasPrefix(line, "@@") { ++ return index, nil ++ } ++ } ++ return 0, errors.New("Could not find any hunks in this patch") ++} ++ + // ModifyPatchForLine takes the original patch, which may contain several hunks, + // and the line number of the line we want to stage + func (p *PatchModifier) ModifyPatchForLine(patch string, lineNumber int) (string, error) { + lines := strings.Split(patch, "\n") +- headerLength := 4 ++ headerLength, err := getHeaderLength(lines) ++ if err != nil { ++ return "", err ++ } + output := strings.Join(lines[0:headerLength], "\n") + "\n" + + hunkStart, err := p.getHunkStart(lines, lineNumber) +@@ -124,13 +140,8 @@ func (p *PatchModifier) getModifiedHunk(patchLines []string, hunkStart int, line + // @@ -14,8 +14,9 @@ import ( + func (p *PatchModifier) updatedHeader(currentHeader string, lineChanges int) (string, error) { + // current counter is the number after the second comma +- re := regexp.MustCompile(`^[^,]+,[^,]+,(\d+)`) +- matches := re.FindStringSubmatch(currentHeader) +- if len(matches) < 2 { +- re = regexp.MustCompile(`^[^,]+,[^+]+\+(\d+)`) +- matches = re.FindStringSubmatch(currentHeader) +- } +- prevLengthString := matches[1] ++ re := regexp.MustCompile(`(\d+) @@`) ++ prevLengthString := re.FindStringSubmatch(currentHeader)[1] + + prevLength, err := strconv.Atoi(prevLengthString) + if err != nil { diff --git a/pkg/gui/commit_message_panel.go b/pkg/gui/commit_message_panel.go index bb139fb78..560f64415 100644 --- a/pkg/gui/commit_message_panel.go +++ b/pkg/gui/commit_message_panel.go @@ -165,6 +165,7 @@ func (gui *Gui) getBufferLength(view *gocui.View) string { return " " + strconv.Itoa(strings.Count(view.Buffer(), "")-1) + " " } +// RenderCommitLength is a function. func (gui *Gui) RenderCommitLength() { if !gui.Config.GetUserConfig().GetBool("gui.commitLength.show") { return diff --git a/pkg/gui/confirmation_panel.go b/pkg/gui/confirmation_panel.go index 0961370b8..75349777c 100644 --- a/pkg/gui/confirmation_panel.go +++ b/pkg/gui/confirmation_panel.go @@ -8,6 +8,7 @@ package gui import ( "strings" + "time" "github.com/fatih/color" "github.com/jesseduffield/gocui" @@ -73,6 +74,7 @@ func (gui *Gui) prepareConfirmationPanel(currentView *gocui.View, title, prompt return nil, err } confirmationView.Title = title + confirmationView.Wrap = true confirmationView.FgColor = gocui.ColorWhite } confirmationView.Clear() @@ -138,7 +140,15 @@ func (gui *Gui) createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, } func (gui *Gui) createErrorPanel(g *gocui.Gui, message string) error { - gui.Log.Error(message) + go func() { + // when reporting is switched on this log call sometimes introduces + // a delay on the error panel popping up. Here I'm adding a second wait + // so that the error is logged while the user is reading the error message + time.Sleep(time.Second) + gui.Log.Error(message) + }() + + // gui.Log.WithField("staging", "staging").Info("creating confirmation panel") currentView := g.CurrentView() colorFunction := color.New(color.FgRed).SprintFunc() coloredMessage := colorFunction(strings.TrimSpace(message)) diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go index 64efd5b9c..6b52970c3 100644 --- a/pkg/gui/files_panel.go +++ b/pkg/gui/files_panel.go @@ -45,6 +45,28 @@ func (gui *Gui) stageSelectedFile(g *gocui.Gui) error { return gui.GitCommand.StageFile(file.Name) } +func (gui *Gui) handleSwitchToStagingPanel(g *gocui.Gui, v *gocui.View) error { + stagingView, err := g.View("staging") + if err != nil { + return err + } + file, err := gui.getSelectedFile(g) + if err != nil { + if err != gui.Errors.ErrNoFiles { + return err + } + return nil + } + if !file.HasUnstagedChanges { + gui.Log.WithField("staging", "staging").Info("making error panel") + return gui.createErrorPanel(g, gui.Tr.SLocalize("FileStagingRequirements")) + } + if err := gui.switchFocus(g, v, stagingView); err != nil { + return err + } + return gui.refreshStagingPanel() +} + func (gui *Gui) handleFilePress(g *gocui.Gui, v *gocui.View) error { file, err := gui.getSelectedFile(g) if err != nil { @@ -188,12 +210,11 @@ func (gui *Gui) handleFileSelect(g *gocui.Gui, v *gocui.View) error { if err := gui.renderfilesOptions(g, file); err != nil { return err } - var content string if file.HasMergeConflicts { return gui.refreshMergePanel(g) } - content = gui.GitCommand.Diff(file) + content := gui.GitCommand.Diff(file, false) return gui.renderString(g, "main", content) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 45284130c..767a25bde 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -74,6 +74,13 @@ type Gui struct { credentials credentials } +type stagingState struct { + StageableLines []int + HunkStarts []int + CurrentLineIndex int + Diff string +} + type guiState struct { Files []*commands.File Branches []*commands.Branch @@ -87,6 +94,7 @@ type guiState struct { EditHistory *stack.Stack Platform commands.Platform Updating bool + StagingState *stagingState } // NewGui builds a new gui handler @@ -210,6 +218,19 @@ func (gui *Gui) layout(g *gocui.Gui) error { v.FgColor = gocui.ColorWhite } + v, err = g.SetView("staging", leftSideWidth+panelSpacing, 0, width-1, optionsTop, gocui.LEFT) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = gui.Tr.SLocalize("StagingTitle") + v.Highlight = true + v.FgColor = gocui.ColorWhite + if _, err := g.SetViewOnBottom("staging"); err != nil { + return err + } + } + if v, err := g.SetView("status", 0, 0, leftSideWidth, statusFilesBoundary, gocui.BOTTOM|gocui.RIGHT); err != nil { if err != gocui.ErrUnknownView { return err diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index b926853ba..28647b245 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -21,6 +21,7 @@ func (b *Binding) GetDisplayStrings() []string { return []string{b.GetKey(), b.Description} } +// GetKey is a function. func (b *Binding) GetKey() string { r, ok := b.Key.(rune) key := "" @@ -34,6 +35,7 @@ func (b *Binding) GetKey() string { return key } +// GetKeybindings is a function. func (gui *Gui) GetKeybindings() []*Binding { bindings := []*Binding{ { @@ -210,6 +212,13 @@ func (gui *Gui) GetKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.handleResetHard, Description: gui.Tr.SLocalize("resetHard"), + }, { + ViewName: "files", + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: gui.handleSwitchToStagingPanel, + Description: gui.Tr.SLocalize("StageLines"), + KeyReadable: "enter", }, { ViewName: "main", Key: gocui.KeyEsc, @@ -398,6 +407,65 @@ func (gui *Gui) GetKeybindings() []*Binding { Key: 'q', Modifier: gocui.ModNone, Handler: gui.handleMenuClose, + }, { + ViewName: "staging", + Key: gocui.KeyEsc, + Modifier: gocui.ModNone, + Handler: gui.handleStagingEscape, + KeyReadable: "esc", + Description: gui.Tr.SLocalize("EscapeStaging"), + }, { + ViewName: "staging", + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.handleStagingPrevLine, + }, { + ViewName: "staging", + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.handleStagingNextLine, + }, { + ViewName: "staging", + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.handleStagingPrevLine, + }, { + ViewName: "staging", + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.handleStagingNextLine, + }, { + ViewName: "staging", + Key: gocui.KeyArrowLeft, + Modifier: gocui.ModNone, + Handler: gui.handleStagingPrevHunk, + }, { + ViewName: "staging", + Key: gocui.KeyArrowRight, + Modifier: gocui.ModNone, + Handler: gui.handleStagingNextHunk, + }, { + ViewName: "staging", + Key: 'h', + Modifier: gocui.ModNone, + Handler: gui.handleStagingPrevHunk, + }, { + ViewName: "staging", + Key: 'l', + Modifier: gocui.ModNone, + Handler: gui.handleStagingNextHunk, + }, { + ViewName: "staging", + Key: gocui.KeySpace, + Modifier: gocui.ModNone, + Handler: gui.handleStageLine, + Description: gui.Tr.SLocalize("StageLine"), + }, { + ViewName: "staging", + Key: 'a', + Modifier: gocui.ModNone, + Handler: gui.handleStageHunk, + Description: gui.Tr.SLocalize("StageHunk"), }, } diff --git a/pkg/gui/recent_repos_panel.go b/pkg/gui/recent_repos_panel.go index add987b32..98da6a9c2 100644 --- a/pkg/gui/recent_repos_panel.go +++ b/pkg/gui/recent_repos_panel.go @@ -14,6 +14,7 @@ type recentRepo struct { path string } +// GetDisplayStrings is a function. func (r *recentRepo) GetDisplayStrings() []string { yellow := color.New(color.FgMagenta) base := filepath.Base(r.path) diff --git a/pkg/gui/staging_panel.go b/pkg/gui/staging_panel.go new file mode 100644 index 000000000..cba7d7638 --- /dev/null +++ b/pkg/gui/staging_panel.go @@ -0,0 +1,235 @@ +package gui + +import ( + "errors" + + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/git" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +func (gui *Gui) refreshStagingPanel() error { + file, err := gui.getSelectedFile(gui.g) + if err != nil { + if err != gui.Errors.ErrNoFiles { + return err + } + return gui.handleStagingEscape(gui.g, nil) + } + + if !file.HasUnstagedChanges { + return gui.handleStagingEscape(gui.g, nil) + } + + // note for custom diffs, we'll need to send a flag here saying not to use the custom diff + diff := gui.GitCommand.Diff(file, true) + colorDiff := gui.GitCommand.Diff(file, false) + + if len(diff) < 2 { + return gui.handleStagingEscape(gui.g, nil) + } + + // parse the diff and store the line numbers of hunks and stageable lines + // TODO: maybe instantiate this at application start + p, err := git.NewPatchParser(gui.Log) + if err != nil { + return nil + } + hunkStarts, stageableLines, err := p.ParsePatch(diff) + if err != nil { + return nil + } + + var currentLineIndex int + if gui.State.StagingState != nil { + end := len(stageableLines) - 1 + if end < gui.State.StagingState.CurrentLineIndex { + currentLineIndex = end + } else { + currentLineIndex = gui.State.StagingState.CurrentLineIndex + } + } else { + currentLineIndex = 0 + } + + gui.State.StagingState = &stagingState{ + StageableLines: stageableLines, + HunkStarts: hunkStarts, + CurrentLineIndex: currentLineIndex, + Diff: diff, + } + + if len(stageableLines) == 0 { + return errors.New("No lines to stage") + } + + if err := gui.focusLineAndHunk(); err != nil { + return err + } + return gui.renderString(gui.g, "staging", colorDiff) +} + +func (gui *Gui) handleStagingEscape(g *gocui.Gui, v *gocui.View) error { + if _, err := gui.g.SetViewOnBottom("staging"); err != nil { + return err + } + + gui.State.StagingState = nil + + return gui.switchFocus(gui.g, nil, gui.getFilesView(gui.g)) +} + +func (gui *Gui) handleStagingPrevLine(g *gocui.Gui, v *gocui.View) error { + return gui.handleCycleLine(true) +} + +func (gui *Gui) handleStagingNextLine(g *gocui.Gui, v *gocui.View) error { + return gui.handleCycleLine(false) +} + +func (gui *Gui) handleStagingPrevHunk(g *gocui.Gui, v *gocui.View) error { + return gui.handleCycleHunk(true) +} + +func (gui *Gui) handleStagingNextHunk(g *gocui.Gui, v *gocui.View) error { + return gui.handleCycleHunk(false) +} + +func (gui *Gui) handleCycleHunk(prev bool) error { + state := gui.State.StagingState + lineNumbers := state.StageableLines + currentLine := lineNumbers[state.CurrentLineIndex] + currentHunkIndex := utils.PrevIndex(state.HunkStarts, currentLine) + var newHunkIndex int + if prev { + if currentHunkIndex == 0 { + newHunkIndex = len(state.HunkStarts) - 1 + } else { + newHunkIndex = currentHunkIndex - 1 + } + } else { + if currentHunkIndex == len(state.HunkStarts)-1 { + newHunkIndex = 0 + } else { + newHunkIndex = currentHunkIndex + 1 + } + } + + state.CurrentLineIndex = utils.NextIndex(lineNumbers, state.HunkStarts[newHunkIndex]) + + return gui.focusLineAndHunk() +} + +func (gui *Gui) handleCycleLine(prev bool) error { + state := gui.State.StagingState + lineNumbers := state.StageableLines + currentLine := lineNumbers[state.CurrentLineIndex] + var newIndex int + if prev { + newIndex = utils.PrevIndex(lineNumbers, currentLine) + } else { + newIndex = utils.NextIndex(lineNumbers, currentLine) + } + state.CurrentLineIndex = newIndex + + return gui.focusLineAndHunk() +} + +// focusLineAndHunk works out the best focus for the staging panel given the +// selected line and size of the hunk +func (gui *Gui) focusLineAndHunk() error { + stagingView := gui.getStagingView(gui.g) + state := gui.State.StagingState + + lineNumber := state.StageableLines[state.CurrentLineIndex] + + // we want the bottom line of the view buffer to ideally be the bottom line + // of the hunk, but if the hunk is too big we'll just go three lines beyond + // the currently selected line so that the user can see the context + var bottomLine int + nextHunkStartIndex := utils.NextIndex(state.HunkStarts, lineNumber) + if nextHunkStartIndex == 0 { + // for now linesHeight is an efficient means of getting the number of lines + // in the patch. However if we introduce word wrap we'll need to update this + bottomLine = stagingView.LinesHeight() - 1 + } else { + bottomLine = state.HunkStarts[nextHunkStartIndex] - 1 + } + + hunkStartIndex := utils.PrevIndex(state.HunkStarts, lineNumber) + hunkStart := state.HunkStarts[hunkStartIndex] + // if it's the first hunk we'll also show the diff header + if hunkStartIndex == 0 { + hunkStart = 0 + } + + _, height := stagingView.Size() + // if this hunk is too big, we will just ensure that the user can at least + // see three lines of context below the cursor + if bottomLine-hunkStart > height { + bottomLine = lineNumber + 3 + } + + return gui.focusLine(lineNumber, bottomLine, stagingView) +} + +// focusLine takes a lineNumber to focus, and a bottomLine to ensure we can see +func (gui *Gui) focusLine(lineNumber int, bottomLine int, v *gocui.View) error { + _, height := v.Size() + overScroll := bottomLine - height + 1 + if overScroll < 0 { + overScroll = 0 + } + if err := v.SetOrigin(0, overScroll); err != nil { + return err + } + if err := v.SetCursor(0, lineNumber-overScroll); err != nil { + return err + } + return nil +} + +func (gui *Gui) handleStageHunk(g *gocui.Gui, v *gocui.View) error { + return gui.handleStageLineOrHunk(true) +} + +func (gui *Gui) handleStageLine(g *gocui.Gui, v *gocui.View) error { + return gui.handleStageLineOrHunk(false) +} + +func (gui *Gui) handleStageLineOrHunk(hunk bool) error { + state := gui.State.StagingState + p, err := git.NewPatchModifier(gui.Log) + if err != nil { + return err + } + + currentLine := state.StageableLines[state.CurrentLineIndex] + var patch string + if hunk { + patch, err = p.ModifyPatchForHunk(state.Diff, state.HunkStarts, currentLine) + } else { + patch, err = p.ModifyPatchForLine(state.Diff, currentLine) + } + if err != nil { + return err + } + + // for logging purposes + // ioutil.WriteFile("patch.diff", []byte(patch), 0600) + + // apply the patch then refresh this panel + // create a new temp file with the patch, then call git apply with that patch + _, err = gui.GitCommand.ApplyPatch(patch) + if err != nil { + return err + } + + if err := gui.refreshFiles(gui.g); err != nil { + return err + } + if err := gui.refreshStagingPanel(); err != nil { + return err + } + return nil +} diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 8eca29b44..9c5d1684b 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -105,6 +105,9 @@ func (gui *Gui) newLineFocused(g *gocui.Gui, v *gocui.View) error { return gui.handleCommitSelect(g, v) case "stash": return gui.handleStashEntrySelect(g, v) + case "staging": + return nil + // return gui.handleStagingSelect(g, v) default: panic(gui.Tr.SLocalize("NoViewMachingNewLineFocusedSwitchStatement")) } @@ -155,6 +158,10 @@ func (gui *Gui) switchFocus(g *gocui.Gui, oldView, newView *gocui.View) error { if _, err := g.SetCurrentView(newView.Name()); err != nil { return err } + if _, err := g.SetViewOnTop(newView.Name()); err != nil { + return err + } + g.Cursor = newView.Editable return gui.newLineFocused(g, newView) @@ -254,7 +261,6 @@ func (gui *Gui) renderString(g *gocui.Gui, viewName, s string) error { output := string(bom.Clean([]byte(s))) output = utils.NormalizeLinefeeds(output) fmt.Fprint(v, output) - v.Wrap = true return nil }) return nil @@ -295,6 +301,11 @@ func (gui *Gui) getBranchesView(g *gocui.Gui) *gocui.View { return v } +func (gui *Gui) getStagingView(g *gocui.Gui) *gocui.View { + v, _ := g.View("staging") + return v +} + func (gui *Gui) trimmedContent(v *gocui.View) string { return strings.TrimSpace(v.Buffer()) } diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go index 2f5a3a404..bd7305735 100644 --- a/pkg/i18n/dutch.go +++ b/pkg/i18n/dutch.go @@ -421,6 +421,30 @@ func addDutch(i18nObject *i18n.Bundle) error { }, &i18n.Message{ ID: "NoAutomaticGitFetchBody", Other: `Lazygit can't use "git fetch" in a private repo use f in the branch panel to run git fetch manually`, + }, &i18n.Message{ + ID: "StageLines", + Other: `stage individual hunks/lines`, + }, &i18n.Message{ + ID: "FileStagingRequirements", + Other: `Can only stage individual lines for tracked files with unstaged changes`, + }, &i18n.Message{ + ID: "StagingTitle", + Other: `Staging`, + }, &i18n.Message{ + ID: "StageHunk", + Other: `stage hunk`, + }, &i18n.Message{ + ID: "StageLine", + Other: `stage line`, + }, &i18n.Message{ + ID: "EscapeStaging", + Other: `return to files panel`, + }, &i18n.Message{ + ID: "CantFindHunks", + Other: `Could not find any hunks in this patch`, + }, &i18n.Message{ + ID: "CantFindHunk", + Other: `Could not find hunk`, }, ) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2ef1d8597..5e0c8109f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -429,6 +429,30 @@ func addEnglish(i18nObject *i18n.Bundle) error { }, &i18n.Message{ ID: "NoAutomaticGitFetchBody", Other: `Lazygit can't use "git fetch" in a private repo use f in the branch panel to run git fetch manually`, + }, &i18n.Message{ + ID: "StageLines", + Other: `stage individual hunks/lines`, + }, &i18n.Message{ + ID: "FileStagingRequirements", + Other: `Can only stage individual lines for tracked files with unstaged changes`, + }, &i18n.Message{ + ID: "StagingTitle", + Other: `Staging`, + }, &i18n.Message{ + ID: "StageHunk", + Other: `stage hunk`, + }, &i18n.Message{ + ID: "StageLine", + Other: `stage line`, + }, &i18n.Message{ + ID: "EscapeStaging", + Other: `return to files panel`, + }, &i18n.Message{ + ID: "CantFindHunks", + Other: `Could not find any hunks in this patch`, + }, &i18n.Message{ + ID: "CantFindHunk", + Other: `Could not find hunk`, }, ) } diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index 5ddfdd79c..0739d6dc6 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -16,10 +16,13 @@ func getDummyLog() *logrus.Entry { log.Out = ioutil.Discard return log.WithField("test", "test") } + +// TestNewLocalizer is a function. func TestNewLocalizer(t *testing.T) { assert.NotNil(t, NewLocalizer(getDummyLog())) } +// TestDetectLanguage is a function. func TestDetectLanguage(t *testing.T) { type scenario struct { langDetector func() (string, error) @@ -46,6 +49,7 @@ func TestDetectLanguage(t *testing.T) { } } +// TestLocalizer is a function. func TestLocalizer(t *testing.T) { type scenario struct { userLang string diff --git a/pkg/i18n/polish.go b/pkg/i18n/polish.go index d2b4a84ed..e7a6e64cc 100644 --- a/pkg/i18n/polish.go +++ b/pkg/i18n/polish.go @@ -404,6 +404,30 @@ func addPolish(i18nObject *i18n.Bundle) error { }, &i18n.Message{ ID: "NoAutomaticGitFetchBody", Other: `Lazygit can't use "git fetch" in a private repo use f in the branch panel to run git fetch manually`, + }, &i18n.Message{ + ID: "StageLines", + Other: `stage individual hunks/lines`, + }, &i18n.Message{ + ID: "FileStagingRequirements", + Other: `Can only stage individual lines for tracked files with unstaged changes`, + }, &i18n.Message{ + ID: "StagingTitle", + Other: `Staging`, + }, &i18n.Message{ + ID: "StageHunk", + Other: `stage hunk`, + }, &i18n.Message{ + ID: "StageLine", + Other: `stage line`, + }, &i18n.Message{ + ID: "EscapeStaging", + Other: `return to files panel`, + }, &i18n.Message{ + ID: "CantFindHunks", + Other: `Could not find any hunks in this patch`, + }, &i18n.Message{ + ID: "CantFindHunk", + Other: `Could not find hunk`, }, ) } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 8e481b3a4..e2a5337e3 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -214,3 +214,24 @@ func IncludesString(list []string, a string) bool { } return false } + +// NextIndex returns the index of the element that comes after the given number +func NextIndex(numbers []int, currentNumber int) int { + for index, number := range numbers { + if number > currentNumber { + return index + } + } + return 0 +} + +// PrevIndex returns the index that comes before the given number, cycling if we reach the end +func PrevIndex(numbers []int, currentNumber int) int { + end := len(numbers) - 1 + for i := end; i >= 0; i -= 1 { + if numbers[i] < currentNumber { + return i + } + } + return end +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 21e1d5031..f03bc087a 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) +// TestSplitLines is a function. func TestSplitLines(t *testing.T) { type scenario struct { multilineString string @@ -36,6 +37,7 @@ func TestSplitLines(t *testing.T) { } } +// TestWithPadding is a function. func TestWithPadding(t *testing.T) { type scenario struct { str string @@ -61,6 +63,7 @@ func TestWithPadding(t *testing.T) { } } +// TestTrimTrailingNewline is a function. func TestTrimTrailingNewline(t *testing.T) { type scenario struct { str string @@ -83,6 +86,7 @@ func TestTrimTrailingNewline(t *testing.T) { } } +// TestNormalizeLinefeeds is a function. func TestNormalizeLinefeeds(t *testing.T) { type scenario struct { byteArray []byte @@ -116,6 +120,7 @@ func TestNormalizeLinefeeds(t *testing.T) { } } +// TestResolvePlaceholderString is a function. func TestResolvePlaceholderString(t *testing.T) { type scenario struct { templateString string @@ -169,6 +174,7 @@ func TestResolvePlaceholderString(t *testing.T) { } } +// TestDisplayArraysAligned is a function. func TestDisplayArraysAligned(t *testing.T) { type scenario struct { input [][]string @@ -197,10 +203,12 @@ type myDisplayable struct { type myStruct struct{} +// GetDisplayStrings is a function. func (d *myDisplayable) GetDisplayStrings() []string { return d.strings } +// TestGetDisplayStringArrays is a function. func TestGetDisplayStringArrays(t *testing.T) { type scenario struct { input []Displayable @@ -222,6 +230,7 @@ func TestGetDisplayStringArrays(t *testing.T) { } } +// TestRenderDisplayableList is a function. func TestRenderDisplayableList(t *testing.T) { type scenario struct { input []Displayable @@ -263,6 +272,7 @@ func TestRenderDisplayableList(t *testing.T) { } } +// TestRenderList is a function. func TestRenderList(t *testing.T) { type scenario struct { input interface{} @@ -301,6 +311,7 @@ func TestRenderList(t *testing.T) { } } +// TestGetPaddedDisplayStrings is a function. func TestGetPaddedDisplayStrings(t *testing.T) { type scenario struct { stringArrays [][]string @@ -321,6 +332,7 @@ func TestGetPaddedDisplayStrings(t *testing.T) { } } +// TestGetPadWidths is a function. func TestGetPadWidths(t *testing.T) { type scenario struct { stringArrays [][]string @@ -347,6 +359,7 @@ func TestGetPadWidths(t *testing.T) { } } +// TestMin is a function. func TestMin(t *testing.T) { type scenario struct { a int @@ -377,6 +390,7 @@ func TestMin(t *testing.T) { } } +// TestIncludesString is a function. func TestIncludesString(t *testing.T) { type scenario struct { list []string @@ -411,3 +425,95 @@ func TestIncludesString(t *testing.T) { assert.EqualValues(t, s.expected, IncludesString(s.list, s.element)) } } + +func TestNextIndex(t *testing.T) { + type scenario struct { + testName string + list []int + element int + expected int + } + + scenarios := []scenario{ + { + // I'm not really fussed about how it behaves here + "no elements", + []int{}, + 1, + 0, + }, + { + "one element", + []int{1}, + 1, + 0, + }, + { + "two elements", + []int{1, 2}, + 1, + 1, + }, + { + "two elements, giving second one", + []int{1, 2}, + 2, + 0, + }, + { + "three elements, giving second one", + []int{1, 2, 3}, + 2, + 2, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.EqualValues(t, s.expected, NextIndex(s.list, s.element)) + }) + } +} + +func TestPrevIndex(t *testing.T) { + type scenario struct { + testName string + list []int + element int + expected int + } + + scenarios := []scenario{ + { + // I'm not really fussed about how it behaves here + "no elements", + []int{}, + 1, + -1, + }, + { + "one element", + []int{1}, + 1, + 0, + }, + { + "two elements", + []int{1, 2}, + 1, + 1, + }, + { + "three elements, giving second one", + []int{1, 2, 3}, + 2, + 0, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.EqualValues(t, s.expected, PrevIndex(s.list, s.element)) + }) + } +} diff --git a/scripts/generate_cheatsheet.go b/scripts/generate_cheatsheet.go deleted file mode 100644 index cfa6d92ad..000000000 --- a/scripts/generate_cheatsheet.go +++ /dev/null @@ -1,54 +0,0 @@ -// run: -// LANG=en go run generate_cheatsheet.go -// to generate Keybindings_en.md file in current directory -// change LANG to generate cheatsheet in different language (if supported) - -package main - -import ( - "fmt" - "os" - "strings" - - "github.com/jesseduffield/lazygit/pkg/app" - "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/utils" -) - -func main() { - appConfig, _ := config.NewAppConfig("", "", "", "", "", new(bool)) - a, _ := app.NewApp(appConfig) - lang := a.Tr.GetLanguage() - name := "Keybindings_" + lang + ".md" - bindings := a.Gui.GetKeybindings() - padWidth := a.Gui.GetMaxKeyLength(bindings) - file, _ := os.Create(name) - current := "v" - content := "" - title := "" - - file.WriteString("# Lazygit " + a.Tr.SLocalize("menu")) - - for _, binding := range bindings { - if key := a.Gui.GetKey(binding); key != "" && (binding.Description != "" || key == "x") { - if binding.ViewName != current { - current = binding.ViewName - if current == "" { - title = a.Tr.SLocalize("GlobalTitle") - } else { - title = a.Tr.SLocalize(strings.Title(current) + "Title") - } - content = fmt.Sprintf("\n\n## %s\n
\n", title) - file.WriteString(content) - } - // workaround to include menu keybinding in cheatsheet - // could not add this Description field directly to keybindings.go, - // because then menu key would be displayed in menu itself and that is undesirable - if key == "x" { - binding.Description = a.Tr.SLocalize("menu") - } - content = fmt.Sprintf("\t%s%s %s\n", key, strings.TrimPrefix(utils.WithPadding(key, padWidth), key), binding.Description) - file.WriteString(content) - } - } -} diff --git a/scripts/push_new_patch.go b/scripts/push_new_patch/main.go similarity index 97% rename from scripts/push_new_patch.go rename to scripts/push_new_patch/main.go index e11865fff..b1660d3c1 100755 --- a/scripts/push_new_patch.go +++ b/scripts/push_new_patch/main.go @@ -1,5 +1,5 @@ // call from project root with -// go run bin/push_new_patch.go +// go run scripts/push_new_patch/main.go // goreleaser expects a $GITHUB_TOKEN env variable to be defined // in order to push the release got github