summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2019-10-30 20:22:30 +1100
committerJesse Duffield <jessedduffield@gmail.com>2019-11-05 19:22:01 +1100
commit081598d98944cdb95bfa649812565127c0592f5e (patch)
tree5cc1d553969cee876542a59cd14ccda62259c891
parent09f268befc1214065c0bf3f7176ac79c18fa8c4a (diff)
rewrite staging to support line ranges and reversing
Now we can stage lines by range and we can also stage reversals meaning we can delete lines or move lines from the working tree to the index and vice versa. I looked at how a few different git guis achieved this to iron out some edge cases, notably ungit and git cola. The end result is disstinct from both those repos, but I know people care about licensing and stuff so I'm happy to revisit this if somebody considers it derivative.
-rw-r--r--pkg/git/patch_modifier.go282
-rw-r--r--pkg/git/patch_modifier_test.go534
-rw-r--r--pkg/git/patch_parser.go164
-rw-r--r--pkg/git/patch_parser_test.go68
4 files changed, 804 insertions, 244 deletions
diff --git a/pkg/git/patch_modifier.go b/pkg/git/patch_modifier.go
index f27040775..b62130324 100644
--- a/pkg/git/patch_modifier.go
+++ b/pkg/git/patch_modifier.go
@@ -1,157 +1,217 @@
package git
import (
+ "fmt"
"regexp"
"strconv"
"strings"
- "github.com/go-errors/errors"
-
- "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
+var headerRegexp = regexp.MustCompile("(?m)^@@ -(\\d+)[^\\+]+\\+(\\d+)[^@]+@@(.*)$")
+
+type PatchHunk struct {
+ header string
+ FirstLineIdx int
+ LastLineIdx int
+ bodyLines []string
}
-// NewPatchModifier builds a new branch list builder
-func NewPatchModifier(log *logrus.Entry) (*PatchModifier, error) {
- return &PatchModifier{
- Log: log,
- }, nil
+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,
+ }
}
-// 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]
+func (hunk *PatchHunk) updatedLinesForRange(firstLineIdx int, lastLineIdx 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
+ }
+ isLineInsideRange := (firstLineIdx <= lineIdx && lineIdx <= lastLineIdx)
+
+ firstChar, content := line[:1], line[1:]
+ transformedFirstChar := transformedFirstChar(firstChar, reverse, isLineInsideRange)
+
+ if isLineInsideRange || (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
+ }
}
- headerLength, err := p.getHeaderLength(lines)
- if err != nil {
- return "", err
+ return newLines
+}
+
+func transformedFirstChar(firstChar string, reverse bool, isLineInsideRange bool) string {
+ if reverse {
+ if !isLineInsideRange && firstChar == "+" {
+ return " "
+ } else if firstChar == "-" {
+ return "+"
+ } else if firstChar == "+" {
+ return "-"
+ } else {
+ return firstChar
+ }
}
- output := strings.Join(lines[0:headerLength], "\n") + "\n"
- output += strings.Join(lines[hunkStart:hunkEnd], "\n") + "\n"
+ if !isLineInsideRange && firstChar == "-" {
+ return " "
+ }
- return output, nil
+ return firstChar
}
-func (p *PatchModifier) getHeaderLength(patchLines []string) (int, error) {
- for index, line := range patchLines {
- if strings.HasPrefix(line, "@@") {
- return index, nil
- }
+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(firstLineIdx int, lastLineIdx int, reverse bool, startOffset int) (int, string) {
+ bodyLines := hunk.updatedLinesForRange(firstLineIdx, lastLineIdx, reverse)
+ startOffset, header, ok := hunk.updatedHeader(bodyLines, startOffset, reverse)
+ if !ok {
+ return startOffset, ""
}
- return 0, errors.New(p.Tr.SLocalize("CantFindHunks"))
+ return startOffset, header + strings.Join(bodyLines, "")
}
-// 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
+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++
+ }
}
- output := strings.Join(lines[0:headerLength], "\n") + "\n"
- hunkStart, err := p.getHunkStart(lines, lineNumber)
- if err != nil {
- return "", err
+ if changeCount == 0 {
+ // if nothing has changed we just return nothing
+ return startOffset, "", false
}
- hunk, err := p.getModifiedHunk(lines, hunkStart, lineNumber)
- if err != nil {
- return "", err
+ // get oldstart, newstart, and heading from header
+ match := headerRegexp.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
}
- output += strings.Join(hunk, "\n")
+ newStart := oldStart + startOffset + newStartOffset
- return output, nil
+ newStartOffset = startOffset + newLength - oldLength
+ formattedHeader := hunk.formatHeader(oldStart, oldLength, newStart, newLength, heading)
+ return newStartOffset, formattedHeader, true
}
-// 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
+func mustConvertToInt(s string) int {
+ i, err := strconv.Atoi(s)
+ if err != nil {
+ panic(err)
+ }
+ return i
+}
+
+func GetHunksFromDiff(diff string) []*PatchHunk {
+ headers := headerRegexp.FindAllString(diff, -1)
+ bodies := headerRegexp.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)
}
}
- return 0, errors.New(p.Tr.SLocalize("CantFindHunk"))
+ hunks := make([]*PatchHunk, len(headers))
+ for index, header := range headers {
+ hunks[index] = newHunk(header, bodies[index], headerFirstLineIndices[index])
+ }
+
+ return hunks
}
-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
- }
+type PatchModifier struct {
+ Log *logrus.Entry
+ filename string
+ hunks []*PatchHunk
+}
+
+func NewPatchModifier(log *logrus.Entry, filename string, diffText string) *PatchModifier {
+ return &PatchModifier{
+ Log: log,
+ filename: filename,
+ hunks: GetHunksFromDiff(diffText),
+ }
+}
+
+func (d *PatchModifier) ModifiedPatchForRange(firstLineIdx int, lastLineIdx int, reverse bool) string {
+ // step one is getting only those hunks which we care about
+ hunksInRange := []*PatchHunk{}
+ for _, hunk := range d.hunks {
+ if hunk.LastLineIdx >= firstLineIdx && hunk.FirstLineIdx <= lastLineIdx {
+ hunksInRange = append(hunksInRange, hunk)
}
- newHunk = append(newHunk, line)
}
- var err error
- newHunk[0], err = p.updatedHeader(newHunk[0], lineChanges)
- if err != nil {
- return nil, err
+ // step 2 is collecting all the hunks with new headers
+ startOffset := 0
+ formattedHunks := ""
+ var formattedHunk string
+ for _, hunk := range hunksInRange {
+ startOffset, formattedHunk = hunk.formatWithChanges(firstLineIdx, lastLineIdx, reverse, startOffset)
+ formattedHunks += formattedHunk
+ }
+
+ if formattedHunks == "" {
+ return ""
}
- return newHunk, nil
+ fileHeader := fmt.Sprintf("--- a/%s\n+++ b/%s\n", d.filename, d.filename)
+
+ return fileHeader + formattedHunks
}
-// 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
+func ModifiedPatch(log *logrus.Entry, filename string, diffText string, firstLineIdx int, lastLineIdx int, reverse bool) string {
+ p := NewPatchModifier(log, filename, diffText)
+ return p.ModifiedPatchForRange(firstLineIdx, lastLineIdx, reverse)
}
diff --git a/pkg/git/patch_modifier_test.go b/pkg/git/patch_modifier_test.go
index 72c3f62a7..e61d32135 100644
--- a/pkg/git/patch_modifier_test.go
+++ b/pkg/git/patch_modifier_test.go
@@ -1,84 +1,510 @@
package git
import (
- "io/ioutil"
+ "fmt"
"testing"
- "github.com/jesseduffield/lazygit/pkg/commands"
"github.com/stretchr/testify/assert"
)
-// NewDummyPatchModifier constructs a new dummy patch modifier for testing
-func NewDummyPatchModifier() *PatchModifier {
- return &PatchModifier{
- Log: commands.NewDummyLog(),
- }
-}
+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
+`
-func TestModifyPatchForLine(t *testing.T) {
+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
- patchFilename string
- lineNumber int
- shouldError bool
- expectedPatchFilename string
+ testName string
+ filename string
+ diffText string
+ firstLineIndex int
+ lastLineIndex int
+ reverse bool
+ expected string
}
scenarios := []scenario{
{
- "Removing one line",
- "testdata/testPatchBefore.diff",
- 8,
- false,
- "testdata/testPatchAfter1.diff",
+ 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
+`,
},
{
- "Adding one line",
- "testdata/testPatchBefore.diff",
- 10,
- false,
- "testdata/testPatchAfter2.diff",
+ 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
+`,
},
{
- "Adding one line in top hunk in diff with multiple hunks",
- "testdata/testPatchBefore2.diff",
- 20,
- false,
- "testdata/testPatchAfter3.diff",
+ 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
+ ...
+ ...
+ ...
+`,
},
{
- "Adding one line in top hunk in diff with multiple hunks",
- "testdata/testPatchBefore2.diff",
- 53,
- false,
- "testdata/testPatchAfter4.diff",
+ 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
+ ...
+ ...
+ ...
+`,
},
{
- "adding unstaged file with a single line",
- "testdata/addedFile.diff",
- 6,
- false,
- "testdata/addedFile.diff",
+ 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) {
- 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)
+ result := ModifiedPatch(nil, s.filename, s.diffText, s.firstLineIndex, s.lastLineIndex, s.reverse)
+ if !assert.Equal(t, s.expected, result) {
+ fmt.Println(result)
}
})
}
diff --git a/pkg/git/patch_parser.go b/pkg/git/patch_parser.go
index 1dbacd01c..21091d1c2 100644
--- a/pkg/git/patch_parser.go
+++ b/pkg/git/patch_parser.go
@@ -1,36 +1,178 @@
package git
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
+ 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
+ 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) (*PatchParser, error) {
+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,
+ Log: log,
+ HunkStarts: hunkStarts, // deprecated
+ StageableLines: stageableLines,
+ PatchLines: patchLines,
+ PatchHunks: patchHunks,
}, nil
}
-func (p *PatchParser) ParsePatch(patch string) ([]int, []int, error) {
+// 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]
+}
+
+func (l *PatchLine) render(selected 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) + coloredString(theme.DefaultTextColor, match[2], selected)
+ }
+
+ var colorAttr color.Attribute
+ switch l.Kind {
+ case PATCH_HEADER:
+ colorAttr = color.Bold
+ case ADDITION:
+ colorAttr = color.FgGreen
+ case DELETION:
+ colorAttr = color.FgRed
+ default:
+ colorAttr = theme.DefaultTextColor
+ }
+
+ return coloredString(colorAttr, content, selected)
+}
+
+func coloredString(colorAttr color.Attribute, str string, selected bool) string {
+ var cl *color.Color
+ if selected {
+ cl = color.New(colorAttr, color.BgBlue)
+ } else {
+ cl = color.New(colorAttr)
+ }
+ return utils.ColoredStringDirect(str, cl)
+}
+
+func parsePatch(patch string) ([]int, []int, []*PatchLine, error) {
lines := strings.Split(patch, "\n")
hunkStarts := []int{}
stageableLines := []int{}
- pastHeader := false
+ pastFirstHunkHeader := false
+ patchLines := make([]*PatchLine, len(lines))
+ var lineKind int
+ var firstChar string
for index, line := range lines {
- if strings.HasPrefix(line, "@@") {
- pastHeader = true
+ lineKind = PATCH_HEADER
+ firstChar = " "
+ if len(line) > 0 {
+ firstChar = line[:1]
+ }
+ 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
+ }
}
- if pastHeader && (strings.HasPrefix(line, "-") || strings.HasPrefix(line, "+")) {
- stageableLines = append(stageableLines, index)
+ 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) string {
+ renderedLines := make([]string, len(p.PatchLines))
+ for index, patchLine := range p.PatchLines {
+ selected := index >= firstLineIndex && index <= lastLineIndex
+ renderedLines[index] = patchLine.render(selected)
+ }
+ 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 current index if it is stageable
+func (p *PatchParser) GetNextStageableLineIndex(currentIndex int) int {
+ for _, lineIndex := range p.StageableLines {
+ if lineIndex >= currentIndex {
+ return lineIndex
}
}
- p.Log.WithField("staging", "staging").Info(stageableLines)
- return hunkStarts, stageableLines, nil
+ return p.StageableLines[len(p.StageableLines)-1]
}
diff --git a/pkg/git/patch_parser_test.go b/pkg/git/patch_parser_test.go
deleted file mode 100644
index ff2d1257f..000000000
--- a/pkg/git/patch_parser_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package git
-
-import (
- "io/ioutil"
- "testing"
-
- "github.com/jesseduffield/lazygit/pkg/commands"
- "github.com/stretchr/testify/assert"
-)
-
-// NewDummyPatchParser constructs a new dummy patch parser for testing
-func NewDummyPatchParser() *PatchParser {
- return &PatchParser{
- Log: commands.NewDummyLog(