Skip to content

Commit e9a59cd

Browse files
committed
Add new functionaltiy for reversing diffs.
ReverseFileDiff() and ReverseMultiFileDiff() accept a diff and return a diff which is the reverse operation -- that is, a diff that when applied would undo the edits of the original diff.
1 parent 9f2e185 commit e9a59cd

8 files changed

+443
-0
lines changed

diff/reverse.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package diff
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"regexp"
8+
)
9+
10+
// ReverseFileDiff takes a diff.FileDiff, and returns the reverse operation.
11+
// This is a FileDiff that undoes the edit of the original.
12+
func ReverseFileDiff(fd *FileDiff) (*FileDiff, error) {
13+
reverse := FileDiff{
14+
OrigName: fd.NewName,
15+
OrigTime: fd.NewTime,
16+
NewName: fd.OrigName,
17+
NewTime: fd.OrigTime,
18+
Extended: fd.Extended,
19+
}
20+
for _, hunk := range fd.Hunks {
21+
invHunk, err := reverseHunk(hunk)
22+
if err != nil {
23+
return nil, err
24+
}
25+
reverse.Hunks = append(reverse.Hunks, invHunk)
26+
}
27+
return &reverse, nil
28+
}
29+
30+
// ReverseMultiFileDiff reverses a series of FileDiffs.
31+
func ReverseMultiFileDiff(fds []*FileDiff) ([]*FileDiff, error) {
32+
var reverse []*FileDiff
33+
for _, fd := range fds {
34+
r, err := ReverseFileDiff(fd)
35+
if err != nil {
36+
return nil, err
37+
}
38+
reverse = append(reverse, r)
39+
}
40+
return reverse, nil
41+
}
42+
43+
// A subhunk represents a portion of a Hunk.Body, split into three sections.
44+
// It consists of zero or more context lines, followed by zero or more orig
45+
// lines and then zero or more new lines.
46+
//
47+
// Each line is stored WITHOUT its starting character, but with the newlines
48+
// included. The final entry in a section may be missing a trailing newline.
49+
//
50+
// A missing newline in orig is represented in a Hunk by OrigNoNewlineAt,
51+
// but is represented here as a missing newline.
52+
type subhunk struct {
53+
context [][]byte
54+
orig [][]byte
55+
new [][]byte
56+
}
57+
58+
// reverseHunk converts a Hunk into its reverse operation.
59+
func reverseHunk(forward *Hunk) (*Hunk, error) {
60+
reverse := Hunk{
61+
OrigStartLine: forward.NewStartLine,
62+
OrigLines: forward.NewLines,
63+
OrigNoNewlineAt: 0, // we may change this below
64+
NewStartLine: forward.OrigStartLine,
65+
NewLines: forward.OrigLines,
66+
Section: forward.Section,
67+
StartPosition: forward.StartPosition,
68+
}
69+
subs, err := toSubhunks(forward)
70+
if err != nil {
71+
return nil, err
72+
}
73+
for _, sub := range subs {
74+
invSub := subhunk{
75+
context: sub.context,
76+
orig: sub.new,
77+
new: sub.orig,
78+
}
79+
for _, line := range invSub.context {
80+
reverse.Body = append(reverse.Body, ' ')
81+
reverse.Body = append(reverse.Body, line...)
82+
}
83+
for _, line := range invSub.orig {
84+
reverse.Body = append(reverse.Body, '-')
85+
reverse.Body = append(reverse.Body, line...)
86+
}
87+
if len(invSub.orig) > 0 && reverse.Body[len(reverse.Body)-1] != '\n' {
88+
// There was a missing newline in `orig`, which we encode in a
89+
// hunk with an offset.
90+
reverse.Body = append(reverse.Body, '\n')
91+
reverse.OrigNoNewlineAt = int32(len(reverse.Body))
92+
}
93+
for _, line := range invSub.new {
94+
reverse.Body = append(reverse.Body, '+')
95+
reverse.Body = append(reverse.Body, line...)
96+
}
97+
}
98+
return &reverse, nil
99+
}
100+
101+
var subhunkLineRe = regexp.MustCompile(`^.[^\n]*(\n|$)`)
102+
103+
func extractLinesStartingWith(from *[]byte, startingWith byte) [][]byte {
104+
var lines [][]byte
105+
for len(*from) > 0 && (*from)[0] == startingWith {
106+
line := subhunkLineRe.Find(*from)
107+
lines = append(lines, line[1:])
108+
*from = (*from)[len(line):]
109+
}
110+
return lines
111+
}
112+
113+
// Extracts the subhunks from a diff.Hunk.
114+
//
115+
// This groups a Hunk's buffer into one or more subhunks, matching the conditions
116+
// of `subhunk` above. This function groups, strips prefix characters, and strips
117+
// a newline for `OrigNoNewlineAt` if necessary.
118+
func toSubhunks(hunk *Hunk) ([]subhunk, error) {
119+
var body []byte = hunk.Body
120+
var subhunks []subhunk
121+
if len(body) == 0 {
122+
return nil, nil
123+
}
124+
for len(body) > 0 {
125+
sh := subhunk{
126+
context: extractLinesStartingWith(&body, ' '),
127+
orig: extractLinesStartingWith(&body, '-'),
128+
new: extractLinesStartingWith(&body, '+'),
129+
}
130+
if len(sh.context) == 0 && len(sh.orig) == 0 && len(sh.new) == 0 {
131+
// The first line didn't start with any expected prefix.
132+
return nil, fmt.Errorf("unexpected character %q at start of line", body[0])
133+
}
134+
subhunks = append(subhunks, sh)
135+
}
136+
if hunk.OrigNoNewlineAt > 0 {
137+
// The Hunk represents a missing newline at the end of an "orig" line with a
138+
// OrigNoNewlineAt index. We represent it here as an actual missing newline.
139+
var lastSubhunk *subhunk = &subhunks[len(subhunks)-1]
140+
s := len(lastSubhunk.orig)
141+
if s == 0 {
142+
return nil, errors.New("inconsistent OrigNoNewlineAt in input")
143+
}
144+
var cut bool
145+
lastSubhunk.orig[s-1], cut = bytes.CutSuffix(lastSubhunk.orig[s-1], []byte("\n"))
146+
if !cut {
147+
return nil, errors.New("missing newline in input")
148+
}
149+
}
150+
return subhunks, nil
151+
}

diff/reverse_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package diff
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
)
11+
12+
func TestReverseHunks(t *testing.T) {
13+
tests := []struct {
14+
inputFile string
15+
wantFile string
16+
}{
17+
{
18+
inputFile: "sample_hunks.diff",
19+
wantFile: "sample_hunks.reversed",
20+
},
21+
{
22+
inputFile: "no_newline_new.diff",
23+
wantFile: "no_newline_new.reversed",
24+
},
25+
{
26+
inputFile: "no_newline_orig.diff",
27+
wantFile: "no_newline_orig.reversed",
28+
},
29+
{
30+
inputFile: "no_newline_both.diff",
31+
wantFile: "no_newline_both.reversed",
32+
},
33+
}
34+
for _, test := range tests {
35+
inputData, err := os.ReadFile(filepath.Join("testdata", test.inputFile))
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
wantData, err := os.ReadFile(filepath.Join("testdata", test.wantFile))
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
input, err := ParseHunks(inputData)
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
48+
var reversed []*Hunk
49+
for _, in := range input {
50+
out, err := reverseHunk(in)
51+
if err != nil {
52+
// This should only fail if the Hunk data structure is inconsistent
53+
t.Errorf("%s: Unexpected reverseHunk() error: %s", test.inputFile, err)
54+
}
55+
reversed = append(reversed, out)
56+
}
57+
gotData, err := PrintHunks(reversed)
58+
if err != nil {
59+
t.Errorf("%s: PrintHunks of reversed data: %s", test.inputFile, err)
60+
}
61+
if !bytes.Equal(wantData, gotData) {
62+
t.Errorf("%s: Reversed hunk does not match expected.\nWant vs got:\n%s",
63+
test.inputFile, cmp.Diff(wantData, gotData))
64+
}
65+
}
66+
}
67+
68+
func TestReverseFileDiff(t *testing.T) {
69+
tests := []struct {
70+
inputFile string
71+
wantFile string
72+
}{
73+
{
74+
inputFile: "sample_file.diff",
75+
wantFile: "sample_file.reversed",
76+
},
77+
}
78+
for _, test := range tests {
79+
inputData, err := os.ReadFile(filepath.Join("testdata", test.inputFile))
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
wantData, err := os.ReadFile(filepath.Join("testdata", test.wantFile))
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
input, err := ParseFileDiff(inputData)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
reversed, err := ReverseFileDiff(input)
92+
if err != nil {
93+
t.Errorf("%s: ReverseFileDiff: %s", test.inputFile, err)
94+
}
95+
gotData, err := PrintFileDiff(reversed)
96+
if err != nil {
97+
t.Errorf("%s: PrintFileDiff of reversed data: %s", test.inputFile, err)
98+
}
99+
if !bytes.Equal(wantData, gotData) {
100+
t.Errorf("%s: Reversed diff does not match expected.\nWant vs got:\n%s",
101+
test.inputFile, cmp.Diff(wantData, gotData))
102+
}
103+
}
104+
}
105+
106+
func TestReverseMultiFileDiff(t *testing.T) {
107+
tests := []struct {
108+
inputFile string
109+
wantFile string
110+
}{
111+
{
112+
inputFile: "sample_file.diff",
113+
wantFile: "sample_file.reversed",
114+
},
115+
{
116+
inputFile: "sample_multi_file.diff",
117+
wantFile: "sample_multi_file.reversed",
118+
},
119+
}
120+
for _, test := range tests {
121+
inputData, err := os.ReadFile(filepath.Join("testdata", test.inputFile))
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
wantData, err := os.ReadFile(filepath.Join("testdata", test.wantFile))
126+
if err != nil {
127+
t.Fatal(err)
128+
}
129+
input, err := ParseMultiFileDiff(inputData)
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
reversed, err := ReverseMultiFileDiff(input)
134+
if err != nil {
135+
t.Errorf("%s: ReverseMultiFileDiff: %s", test.inputFile, err)
136+
}
137+
gotData, err := PrintMultiFileDiff(reversed)
138+
if err != nil {
139+
t.Errorf("%s: PrintMultiFileDiff of reversed data: %s", test.inputFile, err)
140+
}
141+
if !bytes.Equal(wantData, gotData) {
142+
t.Errorf("%s: Reversed diff does not match expected.\nWant vs got:\n%s",
143+
test.inputFile, cmp.Diff(wantData, gotData))
144+
}
145+
}
146+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@@ -1,1 +1,1 @@
2+
-b
3+
\ No newline at end of file
4+
+a
5+
\ No newline at end of file

diff/testdata/no_newline_new.reversed

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@@ -1,2 +1,3 @@
2+
a
3+
-a
4+
\ No newline at end of file
5+
+a
6+
+a
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@@ -1,1 +1,1 @@
2+
-b
3+
+a
4+
\ No newline at end of file

diff/testdata/sample_file.reversed

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
--- newname 2009-10-11 15:12:30.000000000 +0000
2+
+++ oldname 2009-10-11 15:12:20.000000000 +0000
3+
@@ -1,9 +1,3 @@
4+
-This is an important
5+
-notice! It should
6+
-therefore be located at
7+
-the beginning of this
8+
-document!
9+
-
10+
This part of the
11+
document has stayed the
12+
same from version to
13+
@@ -11,10 +5,16 @@
14+
be shown if it doesn't
15+
change. Otherwise, that
16+
would not be helping to
17+
-compress anything.
18+
+compress the size of the
19+
+changes.
20+
+
21+
+This paragraph contains
22+
+text that is outdated.
23+
+It will be deleted in the
24+
+near future.
25+
26+
It is important to spell
27+
-check this document. On
28+
+check this dokument. On
29+
the other hand, a
30+
misspelled word isn't
31+
the end of the world.

diff/testdata/sample_hunks.reversed

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
@@ -1,9 +1,3 @@ Section Header
2+
-This is an important
3+
-notice! It should
4+
-therefore be located at
5+
-the beginning of this
6+
-document!
7+
-
8+
This part of the
9+
document has stayed the
10+
same from version to
11+
@@ -11,10 +5,16 @@
12+
be shown if it doesn't
13+
change. Otherwise, that
14+
would not be helping to
15+
-compress anything.
16+
+compress the size of the
17+
+changes.
18+
+
19+
+This paragraph contains
20+
+text that is outdated.
21+
+It will be deleted in the
22+
+near future.
23+
24+
It is important to spell
25+
-check this document. On
26+
+check this dokument. On
27+
the other hand, a
28+
misspelled word isn't
29+
the end of the world.
30+
@@ -22,7 +22,3 @@
31+
this paragraph needs to
32+
be changed. Things can
33+
be added after it.
34+
-
35+
-This paragraph contains
36+
-important new additions
37+
-to this document.

0 commit comments

Comments
 (0)