Add String() methods to parsed types (#48)

This enables clients to move back and forth between parsed objects and
text patches. The generated patches are semantically equal to the parsed
object and should re-parse to the same object, but may not be
byte-for-byte identical to the original input.

In my testing, formatted text patches are usually identical to the
input, but there may be cases where this is not true. Binary patches
always differ. This is because Go's 'compress/flate' package ends
streams with an empty block instead of adding the end-of-stream flag to
the last non-empty block, like Git's C implementation. Since the streams
will always be different for this reason, I chose to also enable default
compression (the test patches I generated with Git used no compression.)

The main tests for this feature involve parsing, formatting, and then
re-parsing a patch to make sure we get equal objects.

Formatting is handled by a new internal formatter type, which allows
writing all data to the same stream. This isn't exposed publicly right
now, but will be useful if there's a need for more flexible formatting
functions in the future, like formatting to a user-provided io.Writer.
This commit is contained in:
Billy Keyes
2024-08-11 14:57:07 -04:00
committed by GitHub
parent 9e0997ef65
commit 8584cd59af
20 changed files with 746 additions and 4 deletions
+41 -2
View File
@@ -19,8 +19,8 @@ func init() {
}
// base85Decode decodes Base85-encoded data from src into dst. It uses the
// alphabet defined by base85.c in the Git source tree, which appears to be
// unique. src must contain at least len(dst) bytes of encoded data.
// alphabet defined by base85.c in the Git source tree. src must contain at
// least len(dst) bytes of encoded data.
func base85Decode(dst, src []byte) error {
var v uint32
var n, ndst int
@@ -50,3 +50,42 @@ func base85Decode(dst, src []byte) error {
}
return nil
}
// base85Encode encodes src in Base85, writing the result to dst. It uses the
// alphabet defined by base85.c in the Git source tree.
func base85Encode(dst, src []byte) {
var di, si int
encode := func(v uint32) {
dst[di+0] = b85Alpha[(v/(85*85*85*85))%85]
dst[di+1] = b85Alpha[(v/(85*85*85))%85]
dst[di+2] = b85Alpha[(v/(85*85))%85]
dst[di+3] = b85Alpha[(v/85)%85]
dst[di+4] = b85Alpha[v%85]
}
n := (len(src) / 4) * 4
for si < n {
encode(uint32(src[si+0])<<24 | uint32(src[si+1])<<16 | uint32(src[si+2])<<8 | uint32(src[si+3]))
si += 4
di += 5
}
var v uint32
switch len(src) - si {
case 3:
v |= uint32(src[si+2]) << 8
fallthrough
case 2:
v |= uint32(src[si+1]) << 16
fallthrough
case 1:
v |= uint32(src[si+0]) << 24
encode(v)
}
}
// base85Len returns the length of n bytes of Base85 encoded data.
func base85Len(n int) int {
return (n + 3) / 4 * 5
}
+58
View File
@@ -1,6 +1,7 @@
package gitdiff
import (
"bytes"
"testing"
)
@@ -58,3 +59,60 @@ func TestBase85Decode(t *testing.T) {
})
}
}
func TestBase85Encode(t *testing.T) {
tests := map[string]struct {
Input []byte
Output string
}{
"zeroBytes": {
Input: []byte{},
Output: "",
},
"twoBytes": {
Input: []byte{0xCA, 0xFE},
Output: "%KiWV",
},
"fourBytes": {
Input: []byte{0x0, 0x0, 0xCA, 0xFE},
Output: "007GV",
},
"sixBytes": {
Input: []byte{0x0, 0x0, 0xCA, 0xFE, 0xCA, 0xFE},
Output: "007GV%KiWV",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
dst := make([]byte, len(test.Output))
base85Encode(dst, test.Input)
for i, b := range test.Output {
if dst[i] != byte(b) {
t.Errorf("incorrect character at index %d: expected '%c', actual '%c'", i, b, dst[i])
}
}
})
}
}
func FuzzBase85Roundtrip(f *testing.F) {
f.Add([]byte{0x2b, 0x0d})
f.Add([]byte{0xbc, 0xb4, 0x3f})
f.Add([]byte{0xfa, 0x62, 0x05, 0x83, 0x24, 0x39, 0xd5, 0x25})
f.Add([]byte{0x31, 0x59, 0x02, 0xa0, 0x61, 0x12, 0xd9, 0x43, 0xb8, 0x23, 0x1a, 0xb4, 0x02, 0xae, 0xfa, 0xcc, 0x22, 0xad, 0x41, 0xb9, 0xb8})
f.Fuzz(func(t *testing.T, in []byte) {
n := len(in)
dst := make([]byte, base85Len(n))
out := make([]byte, n)
base85Encode(dst, in)
if err := base85Decode(out, dst); err != nil {
t.Fatalf("unexpected error decoding base85 data: %v", err)
}
if !bytes.Equal(in, out) {
t.Errorf("decoded data differed from input data:\n input: %x\n output: %x\nencoding: %s\n", in, out, string(dst))
}
})
}
+277
View File
@@ -0,0 +1,277 @@
package gitdiff
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"strconv"
)
type formatter struct {
w io.Writer
err error
}
func newFormatter(w io.Writer) *formatter {
return &formatter{w: w}
}
func (fm *formatter) Write(p []byte) (int, error) {
if fm.err != nil {
return len(p), nil
}
if _, err := fm.w.Write(p); err != nil {
fm.err = err
}
return len(p), nil
}
func (fm *formatter) WriteString(s string) (int, error) {
fm.Write([]byte(s))
return len(s), nil
}
func (fm *formatter) WriteByte(c byte) error {
fm.Write([]byte{c})
return nil
}
func (fm *formatter) WriteQuotedName(s string) {
qpos := 0
for i := 0; i < len(s); i++ {
ch := s[i]
if q, quoted := quoteByte(ch); quoted {
if qpos == 0 {
fm.WriteByte('"')
}
fm.WriteString(s[qpos:i])
fm.Write(q)
qpos = i + 1
}
}
fm.WriteString(s[qpos:])
if qpos > 0 {
fm.WriteByte('"')
}
}
var quoteEscapeTable = map[byte]byte{
'\a': 'a',
'\b': 'b',
'\t': 't',
'\n': 'n',
'\v': 'v',
'\f': 'f',
'\r': 'r',
'"': '"',
'\\': '\\',
}
func quoteByte(b byte) ([]byte, bool) {
if q, ok := quoteEscapeTable[b]; ok {
return []byte{'\\', q}, true
}
if b < 0x20 || b >= 0x7F {
return []byte{
'\\',
'0' + (b>>6)&0o3,
'0' + (b>>3)&0o7,
'0' + (b>>0)&0o7,
}, true
}
return nil, false
}
func (fm *formatter) FormatFile(f *File) {
fm.WriteString("diff --git ")
var aName, bName string
switch {
case f.OldName == "":
aName = f.NewName
bName = f.NewName
case f.NewName == "":
aName = f.OldName
bName = f.OldName
default:
aName = f.OldName
bName = f.NewName
}
fm.WriteQuotedName("a/" + aName)
fm.WriteByte(' ')
fm.WriteQuotedName("b/" + bName)
fm.WriteByte('\n')
if f.OldMode != 0 {
if f.IsDelete {
fmt.Fprintf(fm, "deleted file mode %o\n", f.OldMode)
} else if f.NewMode != 0 {
fmt.Fprintf(fm, "old mode %o\n", f.OldMode)
}
}
if f.NewMode != 0 {
if f.IsNew {
fmt.Fprintf(fm, "new file mode %o\n", f.NewMode)
} else if f.OldMode != 0 {
fmt.Fprintf(fm, "new mode %o\n", f.NewMode)
}
}
if f.Score > 0 {
if f.IsCopy || f.IsRename {
fmt.Fprintf(fm, "similarity index %d%%\n", f.Score)
} else {
fmt.Fprintf(fm, "dissimilarity index %d%%\n", f.Score)
}
}
if f.IsCopy {
if f.OldName != "" {
fm.WriteString("copy from ")
fm.WriteQuotedName(f.OldName)
fm.WriteByte('\n')
}
if f.NewName != "" {
fm.WriteString("copy to ")
fm.WriteQuotedName(f.NewName)
fm.WriteByte('\n')
}
}
if f.IsRename {
if f.OldName != "" {
fm.WriteString("rename from ")
fm.WriteQuotedName(f.OldName)
fm.WriteByte('\n')
}
if f.NewName != "" {
fm.WriteString("rename to ")
fm.WriteQuotedName(f.NewName)
fm.WriteByte('\n')
}
}
if f.OldOIDPrefix != "" && f.NewOIDPrefix != "" {
fmt.Fprintf(fm, "index %s..%s", f.OldOIDPrefix, f.NewOIDPrefix)
// Mode is only included on the index line when it is not changing
if f.OldMode != 0 && ((f.NewMode == 0 && !f.IsDelete) || f.OldMode == f.NewMode) {
fmt.Fprintf(fm, " %o", f.OldMode)
}
fm.WriteByte('\n')
}
if f.IsBinary {
if f.BinaryFragment == nil {
fm.WriteString("Binary files fmer\n")
} else {
fm.WriteString("GIT binary patch\n")
fm.FormatBinaryFragment(f.BinaryFragment)
if f.ReverseBinaryFragment != nil {
fm.FormatBinaryFragment(f.ReverseBinaryFragment)
}
}
}
// The "---" and "+++" lines only appear for text patches with fragments
if len(f.TextFragments) > 0 {
fm.WriteString("--- ")
if f.OldName == "" {
fm.WriteString("/dev/null")
} else {
fm.WriteQuotedName("a/" + f.OldName)
}
fm.WriteByte('\n')
fm.WriteString("+++ ")
if f.NewName == "" {
fm.WriteString("/dev/null")
} else {
fm.WriteQuotedName("b/" + f.NewName)
}
fm.WriteByte('\n')
for _, frag := range f.TextFragments {
fm.FormatTextFragment(frag)
}
}
}
func (fm *formatter) FormatTextFragment(f *TextFragment) {
fm.FormatTextFragmentHeader(f)
fm.WriteByte('\n')
for _, line := range f.Lines {
fm.WriteString(line.Op.String())
fm.WriteString(line.Line)
if line.NoEOL() {
fm.WriteString("\n\\ No newline at end of file\n")
}
}
}
func (fm *formatter) FormatTextFragmentHeader(f *TextFragment) {
fmt.Fprintf(fm, "@@ -%d,%d +%d,%d @@", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines)
if f.Comment != "" {
fm.WriteByte(' ')
fm.WriteString(f.Comment)
}
}
func (fm *formatter) FormatBinaryFragment(f *BinaryFragment) {
const (
maxBytesPerLine = 52
)
switch f.Method {
case BinaryPatchDelta:
fm.WriteString("delta ")
case BinaryPatchLiteral:
fm.WriteString("literal ")
}
fm.Write(strconv.AppendInt(nil, f.Size, 10))
fm.WriteByte('\n')
data := deflateBinaryChunk(f.Data)
n := (len(data) / maxBytesPerLine) * maxBytesPerLine
buf := make([]byte, base85Len(maxBytesPerLine))
for i := 0; i < n; i += maxBytesPerLine {
base85Encode(buf, data[i:i+maxBytesPerLine])
fm.WriteByte('z')
fm.Write(buf)
fm.WriteByte('\n')
}
if remainder := len(data) - n; remainder > 0 {
buf = buf[0:base85Len(remainder)]
sizeChar := byte(remainder)
if remainder <= 26 {
sizeChar = 'A' + sizeChar - 1
} else {
sizeChar = 'a' + sizeChar - 27
}
base85Encode(buf, data[n:])
fm.WriteByte(sizeChar)
fm.Write(buf)
fm.WriteByte('\n')
}
fm.WriteByte('\n')
}
func deflateBinaryChunk(data []byte) []byte {
var b bytes.Buffer
zw := zlib.NewWriter(&b)
_, _ = zw.Write(data)
_ = zw.Close()
return b.Bytes()
}
+156
View File
@@ -0,0 +1,156 @@
package gitdiff
import (
"bytes"
"fmt"
"os"
"path/filepath"
"slices"
"testing"
)
func TestFormatRoundtrip(t *testing.T) {
patches := []struct {
File string
SkipTextCompare bool
}{
{File: "copy.patch"},
{File: "copy_modify.patch"},
{File: "delete.patch"},
{File: "mode.patch"},
{File: "mode_modify.patch"},
{File: "modify.patch"},
{File: "new.patch"},
{File: "new_empty.patch"},
{File: "new_mode.patch"},
{File: "rename.patch"},
{File: "rename_modify.patch"},
// Due to differences between Go's 'encoding/zlib' package and the zlib
// C library, binary patches cannot be compared directly as the patch
// data is slightly different when re-encoded by Go.
{File: "binary_modify.patch", SkipTextCompare: true},
{File: "binary_new.patch", SkipTextCompare: true},
}
for _, patch := range patches {
t.Run(patch.File, func(t *testing.T) {
b, err := os.ReadFile(filepath.Join("testdata", "string", patch.File))
if err != nil {
t.Fatalf("failed to read patch: %v", err)
}
original := assertParseSingleFile(t, b, "patch")
str := original.String()
if !patch.SkipTextCompare {
if string(b) != str {
t.Errorf("incorrect patch text\nexpected: %q\n actual: %q\n", string(b), str)
}
}
reparsed := assertParseSingleFile(t, []byte(str), "formatted patch")
assertFilesEqual(t, original, reparsed)
})
}
}
func assertParseSingleFile(t *testing.T, b []byte, kind string) *File {
files, _, err := Parse(bytes.NewReader(b))
if err != nil {
t.Fatalf("failed to parse %s: %v", kind, err)
}
if len(files) != 1 {
t.Fatalf("expected %s to contain a single files, but found %d", kind, len(files))
}
return files[0]
}
func assertFilesEqual(t *testing.T, expected, actual *File) {
assertEqual(t, expected.OldName, actual.OldName, "OldName")
assertEqual(t, expected.NewName, actual.NewName, "NewName")
assertEqual(t, expected.IsNew, actual.IsNew, "IsNew")
assertEqual(t, expected.IsDelete, actual.IsDelete, "IsDelete")
assertEqual(t, expected.IsCopy, actual.IsCopy, "IsCopy")
assertEqual(t, expected.IsRename, actual.IsRename, "IsRename")
assertEqual(t, expected.OldMode, actual.OldMode, "OldMode")
assertEqual(t, expected.NewMode, actual.NewMode, "NewMode")
assertEqual(t, expected.OldOIDPrefix, actual.OldOIDPrefix, "OldOIDPrefix")
assertEqual(t, expected.NewOIDPrefix, actual.NewOIDPrefix, "NewOIDPrefix")
assertEqual(t, expected.Score, actual.Score, "Score")
if len(expected.TextFragments) == len(actual.TextFragments) {
for i := range expected.TextFragments {
prefix := fmt.Sprintf("TextFragments[%d].", i)
ef := expected.TextFragments[i]
af := actual.TextFragments[i]
assertEqual(t, ef.Comment, af.Comment, prefix+"Comment")
assertEqual(t, ef.OldPosition, af.OldPosition, prefix+"OldPosition")
assertEqual(t, ef.OldLines, af.OldLines, prefix+"OldLines")
assertEqual(t, ef.NewPosition, af.NewPosition, prefix+"NewPosition")
assertEqual(t, ef.NewLines, af.NewLines, prefix+"NewLines")
assertEqual(t, ef.LinesAdded, af.LinesAdded, prefix+"LinesAdded")
assertEqual(t, ef.LinesDeleted, af.LinesDeleted, prefix+"LinesDeleted")
assertEqual(t, ef.LeadingContext, af.LeadingContext, prefix+"LeadingContext")
assertEqual(t, ef.TrailingContext, af.TrailingContext, prefix+"TrailingContext")
if !slices.Equal(ef.Lines, af.Lines) {
t.Errorf("%sLines: expected %#v, actual %#v", prefix, ef.Lines, af.Lines)
}
}
} else {
t.Errorf("TextFragments: expected length %d, actual length %d", len(expected.TextFragments), len(actual.TextFragments))
}
assertEqual(t, expected.IsBinary, actual.IsBinary, "IsBinary")
if expected.BinaryFragment != nil {
if actual.BinaryFragment == nil {
t.Errorf("BinaryFragment: expected non-nil, actual is nil")
} else {
ef := expected.BinaryFragment
af := expected.BinaryFragment
assertEqual(t, ef.Method, af.Method, "BinaryFragment.Method")
assertEqual(t, ef.Size, af.Size, "BinaryFragment.Size")
if !slices.Equal(ef.Data, af.Data) {
t.Errorf("BinaryFragment.Data: expected %#v, actual %#v", ef.Data, af.Data)
}
}
} else if actual.BinaryFragment != nil {
t.Errorf("BinaryFragment: expected nil, actual is non-nil")
}
if expected.ReverseBinaryFragment != nil {
if actual.ReverseBinaryFragment == nil {
t.Errorf("ReverseBinaryFragment: expected non-nil, actual is nil")
} else {
ef := expected.ReverseBinaryFragment
af := expected.ReverseBinaryFragment
assertEqual(t, ef.Method, af.Method, "ReverseBinaryFragment.Method")
assertEqual(t, ef.Size, af.Size, "ReverseBinaryFragment.Size")
if !slices.Equal(ef.Data, af.Data) {
t.Errorf("ReverseBinaryFragment.Data: expected %#v, actual %#v", ef.Data, af.Data)
}
}
} else if actual.ReverseBinaryFragment != nil {
t.Errorf("ReverseBinaryFragment: expected nil, actual is non-nil")
}
}
func assertEqual[T comparable](t *testing.T, expected, actual T, name string) {
if expected != actual {
t.Errorf("%s: expected %#v, actual %#v", name, expected, actual)
}
}
+28
View File
@@ -0,0 +1,28 @@
package gitdiff
import (
"strings"
"testing"
)
func TestFormatter_WriteQuotedName(t *testing.T) {
tests := []struct {
Input string
Expected string
}{
{"noquotes.txt", `noquotes.txt`},
{"no quotes.txt", `no quotes.txt`},
{"new\nline", `"new\nline"`},
{"escape\x1B null\x00", `"escape\033 null\000"`},
{"snowman \u2603 snowman", `"snowman \342\230\203 snowman"`},
{"\"already quoted\"", `"\"already quoted\""`},
}
for _, test := range tests {
var b strings.Builder
newFormatter(&b).WriteQuotedName(test.Input)
if b.String() != test.Expected {
t.Errorf("expected %q, got %q", test.Expected, b.String())
}
}
}
+33 -2
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"strings"
)
// File describes changes to a single file. It can be either a text file or a
@@ -38,6 +39,15 @@ type File struct {
ReverseBinaryFragment *BinaryFragment
}
// String returns a git diff representation of this file. The value can be
// parsed by this library to obtain the same File, but may not be the same as
// the original input.
func (f *File) String() string {
var diff strings.Builder
newFormatter(&diff).FormatFile(f)
return diff.String()
}
// TextFragment describes changed lines starting at a specific line in a text file.
type TextFragment struct {
Comment string
@@ -57,9 +67,20 @@ type TextFragment struct {
Lines []Line
}
// Header returns the canonical header of this fragment.
// String returns a git diff format of this fragment. See [File.String] for
// more details on this format.
func (f *TextFragment) String() string {
var diff strings.Builder
newFormatter(&diff).FormatTextFragment(f)
return diff.String()
}
// Header returns a git diff header of this fragment. See [File.String] for
// more details on this format.
func (f *TextFragment) Header() string {
return fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines, f.Comment)
var hdr strings.Builder
newFormatter(&hdr).FormatTextFragmentHeader(f)
return hdr.String()
}
// Validate checks that the fragment is self-consistent and appliable. Validate
@@ -197,3 +218,13 @@ const (
// BinaryPatchLiteral indicates the data is the exact file content
BinaryPatchLiteral
)
// String returns a git diff format of this fragment. Due to differences in
// zlib implementation between Go and Git, encoded binary data in the result
// will likely differ from what Git produces for the same input. See
// [File.String] for more details on this format.
func (f *BinaryFragment) String() string {
var diff strings.Builder
newFormatter(&diff).FormatBinaryFragment(f)
return diff.String()
}
+9
View File
@@ -0,0 +1,9 @@
diff --git a/file.bin b/file.bin
index a7f4d5d6975ec021016c02b6d58345ebf434f38c..bdc9a70f055892146612dcdb413f0e339faaa0df 100644
GIT binary patch
delta 66
QcmeZhVVvM$!$1K50C&Ox;s5{u
delta 5
McmZo+^qAlQ00i9urT_o{
+11
View File
@@ -0,0 +1,11 @@
diff --git a/file.bin b/file.bin
new file mode 100644
index 0000000000000000000000000000000000000000..a7f4d5d6975ec021016c02b6d58345ebf434f38c
GIT binary patch
literal 72
zcmV-O0Jr~td-`u6JcK&{KDK=<a#;v1^LR5&K)zQ0=Goz82(?nJ6_nD`f#8O9p}}{P
eiXim+rDI+BDadMQmMsO5Sw@;DbrCA+PamP;Ng_@F
literal 0
HcmV?d00001
+4
View File
@@ -0,0 +1,4 @@
diff --git a/file.txt b/numbers.txt
similarity index 100%
copy from file.txt
copy to numbers.txt
+21
View File
@@ -0,0 +1,21 @@
diff --git a/file.txt b/numbers.txt
similarity index 57%
copy from file.txt
copy to numbers.txt
index c9e9e05..6c4a3e0 100644
--- a/file.txt
+++ b/numbers.txt
@@ -1,6 +1,6 @@
one
two
-three
+three three three
four
five
six
@@ -8,3 +8,5 @@ seven
eight
nine
ten
+eleven
+twelve
+16
View File
@@ -0,0 +1,16 @@
diff --git a/file.txt b/file.txt
deleted file mode 100644
index c9e9e05..0000000
--- a/file.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-one
-two
-three
-four
-five
-six
-seven
-eight
-nine
-ten
+3
View File
@@ -0,0 +1,3 @@
diff --git a/file.txt b/file.txt
old mode 100644
new mode 100755
+10
View File
@@ -0,0 +1,10 @@
diff --git a/script.sh b/script.sh
old mode 100644
new mode 100755
index 7a870bd..68d501e
--- a/script.sh
+++ b/script.sh
@@ -1,2 +1,2 @@
#!/bin/bash
-echo "Hello World"
+echo "Hello, World!"
+16
View File
@@ -0,0 +1,16 @@
diff --git a/file.txt b/file.txt
index c9e9e05..7d5fdc6 100644
--- a/file.txt
+++ b/file.txt
@@ -3,8 +3,10 @@ two
three
four
five
-six
+six six six six six six
seven
eight
nine
ten
+eleven
+twelve
+16
View File
@@ -0,0 +1,16 @@
diff --git a/file.txt b/file.txt
new file mode 100644
index 0000000..c9e9e05
--- /dev/null
+++ b/file.txt
@@ -0,0 +1,10 @@
+one
+two
+three
+four
+five
+six
+seven
+eight
+nine
+ten
+3
View File
@@ -0,0 +1,3 @@
diff --git a/file.txt b/file.txt
new file mode 100644
index 0000000..e69de29
+16
View File
@@ -0,0 +1,16 @@
diff --git a/file.sh b/file.sh
new file mode 100755
index 0000000..c9e9e05
--- /dev/null
+++ b/file.sh
@@ -0,0 +1,10 @@
+one
+two
+three
+four
+five
+six
+seven
+eight
+nine
+ten
+4
View File
@@ -0,0 +1,4 @@
diff --git a/file.txt b/numbers.txt
similarity index 100%
rename from file.txt
rename to numbers.txt
+18
View File
@@ -0,0 +1,18 @@
diff --git a/file.txt b/numbers.txt
similarity index 77%
rename from file.txt
rename to numbers.txt
index c9e9e05..a6b31d6 100644
--- a/file.txt
+++ b/numbers.txt
@@ -3,8 +3,9 @@ two
three
four
five
-six
+ six
seven
eight
nine
ten
+eleven