summaryrefslogtreecommitdiffstats
path: root/pkg/commands/patch
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2020-08-15 11:18:40 +1000
committerJesse Duffield <jessedduffield@gmail.com>2020-08-15 11:41:37 +1000
commit826d1660c97b7c5c55420ffed21eaa5f16118118 (patch)
tree8f2f65a3d2617aa242034273c4a41b6ec5aea659 /pkg/commands/patch
parent291a8e4de0f5d5557cf6fbd7b5b6b9d42bcaa16e (diff)
move patch stuff into its own package
Diffstat (limited to 'pkg/commands/patch')
-rw-r--r--pkg/commands/patch/hunk.go142
-rw-r--r--pkg/commands/patch/patch_manager.go238
-rw-r--r--pkg/commands/patch/patch_modifier.go158
-rw-r--r--pkg/commands/patch/patch_modifier_test.go548
-rw-r--r--pkg/commands/patch/patch_parser.go213
5 files changed, 1299 insertions, 0 deletions
diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go
new file mode 100644
index 000000000..bbb2d54ff
--- /dev/null
+++ b/pkg/commands/patch/hunk.go
@@ -0,0 +1,142 @@
+package patch
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type PatchHunk struct {
+ FirstLineIdx int
+ oldStart int
+ newStart int
+ heading string
+ bodyLines []string
+}
+
+func (hunk *PatchHunk) LastLineIdx() int {
+ return hunk.FirstLineIdx + len(hunk.bodyLines)
+}
+
+func newHunk(lines []string, firstLineIdx int) *PatchHunk {
+ header := lines[0]
+ bodyLines := lines[1:]
+
+ oldStart, newStart, heading := headerInfo(header)
+
+ return &PatchHunk{
+ oldStart: oldStart,
+ newStart: newStart,
+ heading: heading,
+ FirstLineIdx: firstLineIdx,
+ bodyLines: bodyLines,
+ }
+}
+
+func headerInfo(header string) (int, int, string) {
+ match := hunkHeaderRegexp.FindStringSubmatch(header)
+
+ oldStart := utils.MustConvertToInt(match[1])
+ newStart := utils.MustConvertToInt(match[2])
+ heading := match[3]
+
+ return oldStart, newStart, heading
+}
+
+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 := nLinesWithPrefix(newBodyLines, []string{"+", "-"})
+ oldLength := nLinesWithPrefix(newBodyLines, []string{" ", "-"})
+ newLength := nLinesWithPrefix(newBodyLines, []string{"+", " "})
+
+ if changeCount == 0 {
+ // if nothing has changed we just return nothing
+ return startOffset, "", false
+ }
+
+ var oldStart int
+ if reverse {
+ oldStart = hunk.newStart
+ } else {
+ oldStart = hunk.oldStart
+ }
+
+ 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, hunk.heading)
+ return newStartOffset, formattedHeader, true
+}
diff --git a/pkg/commands/patch/patch_manager.go b/pkg/commands/patch/patch_manager.go
new file mode 100644
index 000000000..d248381f6
--- /dev/null
+++ b/pkg/commands/patch/patch_manager.go
@@ -0,0 +1,238 @@
+package patch
+
+import (
+ "sort"
+
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sirupsen/logrus"
+)
+
+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
+)
+
+type fileInfo struct {
+ mode int // one of WHOLE/PART
+ includedLineIndices []int
+ diff string
+}
+
+type applyPatchFunc func(patch string, flags ...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 PatchManager
+func NewPatchManager(log *logrus.Entry, applyPatch applyPatchFunc) *PatchManager {
+ return &PatchManager{
+ Log: log,
+ ApplyPatch: applyPatch,
+ }
+}
+
+// NewPatchManager returns a new PatchManager
+func (p *PatchManager) Start(commitSha string, diffMap map[string]string) {
+ p.CommitSha = commitSha
+ p.fileInfoMap = map[string]*fileInfo{}
+ for filename, diff := range diffMap {
+ p.fileInfoMap[filename] = &fileInfo{
+ mode: UNSELECTED,
+ diff: diff,
+ }
+ }
+}
+
+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, keepOriginalHeader 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
+ return ModifiedPatchForLines(p.Log, filename, info.diff, info.includedLineIndices, reverse, keepOriginalHeader)
+ default:
+ return ""
+ }
+}
+
+func (p *PatchManager) RenderPatchForFile(filename string, plain bool, reverse bool, keepOriginalHeader bool) string {
+ patch := p.RenderPlainPatchForFile(filename, reverse, keepOriginalHeader)
+ 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, true)
+ if patch != "" {
+ output = append(output, patch)
+ }
+ }
+
+ return output
+}
+
+func (p *PatchManager) RenderAggregatedPatchColored(plain bool) string {
+ result := ""
+ for _, patch := range p.RenderEachFilePatch(plain) {
+ if patch != "" {
+ result += patch + "\n"
+ }
+ }
+ return result
+}
+
+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
+ }
+
+ applyFlags := []string{"index", "3way"}
+ reverseOnGenerate := false
+ if reverse {
+ if info.mode == WHOLE {
+ applyFlags = append(applyFlags, "reverse")
+ } else {
+ reverseOnGenerate = true
+ }
+ }
+
+ var err error
+ // first run we try with the original header, then without
+ for _, keepOriginalHeader := range []bool{true, false} {
+ patch := p.RenderPatchForFile(filename, true, reverseOnGenerate, keepOriginalHeader)
+ if patch == "" {
+ continue
+ }
+ if err = p.ApplyPatch(patch, applyFlags...); err != nil {
+ continue
+ }
+ break
+ }
+
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// clears the patch
+func (p *PatchManager) Reset() {
+ p.CommitSha = ""
+ p.fileInfoMap = map[string]*fileInfo{}
+}
+
+func (p *PatchManager) CommitSelected() bool {
+ return p.CommitSha != ""
+}
+
+func (p *PatchManager) IsEmpty() bool {
+ for _, fileInfo := range p.fileInfoMap {
+ if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/pkg/commands/patch/patch_modifier.go b/pkg/commands/patch/patch_modifier.go
new file mode 100644
index 000000000..c2bbc60f6
--- /dev/null
+++ b/pkg/commands/patch/patch_modifier.go
@@ -0,0 +1,158 @@
+package patch
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/sirupsen/logrus"
+)
+
+var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
+var patchHeaderRegexp = regexp.MustCompile(`(?ms)(^diff.*?)^@@`)
+
+func GetHeaderFromDiff(diff string) string {
+ match := patchHeaderRegexp.FindStringSubmatch(diff)
+ if len(match) <= 1 {
+ return ""
+ }
+ return match[1]
+}
+
+func GetHunksFromDiff(diff string) []*PatchHunk {
+ hunks := []*PatchHunk{}
+ firstLineIdx := -1
+ var hunkLines []string
+ pastDiffHeader := false
+
+ for lineIdx, line := range strings.SplitAfter(diff, "\n") {
+ isHunkHeader := strings.HasPrefix(line, "@@ -")
+
+ if isHunkHeader {
+ if pastDiffHeader { // we need to persist the current hunk
+ hunks = append(hunks, newHunk(hunkLines, firstLineIdx))
+ }
+ pastDiffHeader = true
+ firstLineIdx = lineIdx
+ hunkLines = []string{line}
+ continue
+ }
+
+ if !pastDiffHeader { // skip through the stuff that precedes the first hunk
+ continue
+ }
+
+ hunkLines = append(hunkLines, line)
+ }
+
+ if pastDiffHeader {
+ hunks = append(hunks, newHunk(hunkLines, firstLineIdx))
+ }
+
+ 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)
+}
+
+func ModifiedPatchForLines(log *logrus.Entry, filename string, diffText string, includedLineIndices []int, reverse bool, keepOriginalHeader bool) string {
+ p := NewPatchModifier(log, filename, diffText)
+ return p.ModifiedPatchForLines(includedLineIndices, reverse, keepOriginalHeader)
+}
+
+// I want to know, given a hunk, what line a given index is on
+func (hunk *PatchHunk) LineNumberOfLine(idx int) int {
+ lines := hunk.bodyLines[0 : idx-hunk.FirstLineIdx-1]
+
+ offset := nLinesWithPrefix(lines, []string{"+", " "})
+
+ return hunk.newStart + offset
+}
+
+func nLinesWithPrefix(lines []string, chars []string) int {
+ result := 0
+ for _, line := range lines {
+ for _, char := range chars {
+ if line[:1] == char {
+ result++
+ }
+ }
+ }
+ return result
+}
diff --git a/pkg/commands/patch/patch_modifier_test.go b/pkg/commands/patch/patch_modifier_test.go
new file mode 100644
index 000000000..8b866019b
--- /dev/null
+++ b/pkg/commands/patch/patch_modifier_test.go
@@ -0,0 +1,548 @@
+package patch
+
+import (
+ "fmt"
+ "strings"
+ "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
+`
+
+const exampleHunk = `@@ -1,5 +1,5 @@
+ apple
+-grape
++orange
+...
+...
+...
+`
+
+// 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, false)
+ if !assert.Equal(t, s.expected, result) {
+ fmt.Println(result)
+ }
+ })
+ }
+}
+
+func TestLineNumberOfLine(t *testing.T) {
+ type scenario struct {
+ testName string
+ hunk *PatchHunk
+ idx int
+ expected int
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "nothing selected",
+ hunk: newHunk(strings.SplitAfter(exampleHunk, "\n"), 10),
+ idx: 15,
+ expected: 3,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ result := s.hunk.LineNumberOfLine(s.idx)
+ if !assert.Equal(t, s.expected, result) {
+ fmt.Println(result)
+ }
+ })
+ }
+}
diff --git a/pkg/commands/patch/patch_parser.go b/pkg/commands/patch/patch_parser.go
new file mode 100644
index 000000000..55554fec3
--- /dev/null
+++ b/pkg/commands/patch/patch_parser.go
@@ -0,0 +1,213 @@
+package patch
+
+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 []*