+6
-6
.github/workflows/go.yml
+6
-6
.github/workflows/go.yml
···
9
9
name: Verify
10
10
runs-on: ubuntu-latest
11
11
steps:
12
-
- name: Set up Go 1.19
13
-
uses: actions/setup-go@v3
12
+
- name: Set up Go 1.21
13
+
uses: actions/setup-go@v5
14
14
with:
15
-
go-version: 1.19
15
+
go-version: 1.21
16
16
17
17
- name: Check out code into the Go module directory
18
-
uses: actions/checkout@v2
18
+
uses: actions/checkout@v4
19
19
20
20
- name: Lint
21
-
uses: golangci/golangci-lint-action@v3
21
+
uses: golangci/golangci-lint-action@v7
22
22
with:
23
-
version: v1.49
23
+
version: v2.0
24
24
25
25
- name: Test
26
26
run: go test -v ./...
+40
-13
.golangci.yml
+40
-13
.golangci.yml
···
1
+
version: "2"
2
+
1
3
run:
2
4
tests: false
3
5
4
6
linters:
5
-
disable-all: true
7
+
default: none
6
8
enable:
7
-
- deadcode
8
9
- errcheck
9
-
- gofmt
10
-
- goimports
11
-
- golint
12
10
- govet
13
11
- ineffassign
14
12
- misspell
15
-
- typecheck
13
+
- revive
16
14
- unconvert
17
-
- varcheck
18
-
19
-
issues:
20
-
exclude-use-default: false
15
+
- unused
16
+
settings:
17
+
errcheck:
18
+
exclude-functions:
19
+
- (*github.com/bluekeyes/go-gitdiff/gitdiff.formatter).Write
20
+
- (*github.com/bluekeyes/go-gitdiff/gitdiff.formatter).WriteString
21
+
- (*github.com/bluekeyes/go-gitdiff/gitdiff.formatter).WriteByte
22
+
- fmt.Fprintf(*github.com/bluekeyes/go-gitdiff/gitdiff.formatter)
23
+
revive:
24
+
rules:
25
+
- name: context-keys-type
26
+
- name: time-naming
27
+
- name: var-declaration
28
+
- name: unexported-return
29
+
- name: errorf
30
+
- name: blank-imports
31
+
- name: context-as-argument
32
+
- name: dot-imports
33
+
- name: error-return
34
+
- name: error-strings
35
+
- name: error-naming
36
+
- name: exported
37
+
- name: increment-decrement
38
+
- name: var-naming
39
+
- name: package-comments
40
+
- name: range
41
+
- name: receiver-naming
42
+
- name: indent-error-flow
21
43
22
-
linter-settings:
23
-
goimports:
24
-
local-prefixes: github.com/bluekeyes/go-gitdiff
44
+
formatters:
45
+
enable:
46
+
- gofmt
47
+
- goimports
48
+
settings:
49
+
goimports:
50
+
local-prefixes:
51
+
- github.com/bluekeyes/go-gitdiff
+1
-1
README.md
+1
-1
README.md
···
4
4
5
5
A Go library for parsing and applying patches generated by `git diff`, `git
6
6
show`, and `git format-patch`. It can also parse and apply unified diffs
7
-
generated by the standard `diff` tool.
7
+
generated by the standard GNU `diff` tool.
8
8
9
9
It supports standard line-oriented text patches and Git binary patches, and
10
10
aims to parse anything accepted by the `git apply` command.
+1
gitdiff/apply_test.go
+1
gitdiff/apply_test.go
···
22
22
"changeStart": {Files: getApplyFiles("text_fragment_change_start")},
23
23
"changeMiddle": {Files: getApplyFiles("text_fragment_change_middle")},
24
24
"changeEnd": {Files: getApplyFiles("text_fragment_change_end")},
25
+
"changeEndEOL": {Files: getApplyFiles("text_fragment_change_end_eol")},
25
26
"changeExact": {Files: getApplyFiles("text_fragment_change_exact")},
26
27
"changeSingleNoEOL": {Files: getApplyFiles("text_fragment_change_single_noeol")},
27
28
+41
-2
gitdiff/base85.go
+41
-2
gitdiff/base85.go
···
19
19
}
20
20
21
21
// base85Decode decodes Base85-encoded data from src into dst. It uses the
22
-
// alphabet defined by base85.c in the Git source tree, which appears to be
23
-
// unique. src must contain at least len(dst) bytes of encoded data.
22
+
// alphabet defined by base85.c in the Git source tree. src must contain at
23
+
// least len(dst) bytes of encoded data.
24
24
func base85Decode(dst, src []byte) error {
25
25
var v uint32
26
26
var n, ndst int
···
50
50
}
51
51
return nil
52
52
}
53
+
54
+
// base85Encode encodes src in Base85, writing the result to dst. It uses the
55
+
// alphabet defined by base85.c in the Git source tree.
56
+
func base85Encode(dst, src []byte) {
57
+
var di, si int
58
+
59
+
encode := func(v uint32) {
60
+
dst[di+0] = b85Alpha[(v/(85*85*85*85))%85]
61
+
dst[di+1] = b85Alpha[(v/(85*85*85))%85]
62
+
dst[di+2] = b85Alpha[(v/(85*85))%85]
63
+
dst[di+3] = b85Alpha[(v/85)%85]
64
+
dst[di+4] = b85Alpha[v%85]
65
+
}
66
+
67
+
n := (len(src) / 4) * 4
68
+
for si < n {
69
+
encode(uint32(src[si+0])<<24 | uint32(src[si+1])<<16 | uint32(src[si+2])<<8 | uint32(src[si+3]))
70
+
si += 4
71
+
di += 5
72
+
}
73
+
74
+
var v uint32
75
+
switch len(src) - si {
76
+
case 3:
77
+
v |= uint32(src[si+2]) << 8
78
+
fallthrough
79
+
case 2:
80
+
v |= uint32(src[si+1]) << 16
81
+
fallthrough
82
+
case 1:
83
+
v |= uint32(src[si+0]) << 24
84
+
encode(v)
85
+
}
86
+
}
87
+
88
+
// base85Len returns the length of n bytes of Base85 encoded data.
89
+
func base85Len(n int) int {
90
+
return (n + 3) / 4 * 5
91
+
}
+58
gitdiff/base85_test.go
+58
gitdiff/base85_test.go
···
1
1
package gitdiff
2
2
3
3
import (
4
+
"bytes"
4
5
"testing"
5
6
)
6
7
···
58
59
})
59
60
}
60
61
}
62
+
63
+
func TestBase85Encode(t *testing.T) {
64
+
tests := map[string]struct {
65
+
Input []byte
66
+
Output string
67
+
}{
68
+
"zeroBytes": {
69
+
Input: []byte{},
70
+
Output: "",
71
+
},
72
+
"twoBytes": {
73
+
Input: []byte{0xCA, 0xFE},
74
+
Output: "%KiWV",
75
+
},
76
+
"fourBytes": {
77
+
Input: []byte{0x0, 0x0, 0xCA, 0xFE},
78
+
Output: "007GV",
79
+
},
80
+
"sixBytes": {
81
+
Input: []byte{0x0, 0x0, 0xCA, 0xFE, 0xCA, 0xFE},
82
+
Output: "007GV%KiWV",
83
+
},
84
+
}
85
+
86
+
for name, test := range tests {
87
+
t.Run(name, func(t *testing.T) {
88
+
dst := make([]byte, len(test.Output))
89
+
base85Encode(dst, test.Input)
90
+
for i, b := range test.Output {
91
+
if dst[i] != byte(b) {
92
+
t.Errorf("incorrect character at index %d: expected '%c', actual '%c'", i, b, dst[i])
93
+
}
94
+
}
95
+
})
96
+
}
97
+
}
98
+
99
+
func FuzzBase85Roundtrip(f *testing.F) {
100
+
f.Add([]byte{0x2b, 0x0d})
101
+
f.Add([]byte{0xbc, 0xb4, 0x3f})
102
+
f.Add([]byte{0xfa, 0x62, 0x05, 0x83, 0x24, 0x39, 0xd5, 0x25})
103
+
f.Add([]byte{0x31, 0x59, 0x02, 0xa0, 0x61, 0x12, 0xd9, 0x43, 0xb8, 0x23, 0x1a, 0xb4, 0x02, 0xae, 0xfa, 0xcc, 0x22, 0xad, 0x41, 0xb9, 0xb8})
104
+
105
+
f.Fuzz(func(t *testing.T, in []byte) {
106
+
n := len(in)
107
+
dst := make([]byte, base85Len(n))
108
+
out := make([]byte, n)
109
+
110
+
base85Encode(dst, in)
111
+
if err := base85Decode(out, dst); err != nil {
112
+
t.Fatalf("unexpected error decoding base85 data: %v", err)
113
+
}
114
+
if !bytes.Equal(in, out) {
115
+
t.Errorf("decoded data differed from input data:\n input: %x\n output: %x\nencoding: %s\n", in, out, string(dst))
116
+
}
117
+
})
118
+
}
+11
-4
gitdiff/binary.go
+11
-4
gitdiff/binary.go
···
50
50
}
51
51
52
52
func (p *parser) ParseBinaryMarker() (isBinary bool, hasData bool, err error) {
53
-
switch p.Line(0) {
54
-
case "GIT binary patch\n":
53
+
line := p.Line(0)
54
+
switch {
55
+
case line == "GIT binary patch\n":
55
56
hasData = true
56
-
case "Binary files differ\n":
57
-
case "Files differ\n":
57
+
case isBinaryNoDataMarker(line):
58
58
default:
59
59
return false, false, nil
60
60
}
···
63
63
return false, false, err
64
64
}
65
65
return true, hasData, nil
66
+
}
67
+
68
+
func isBinaryNoDataMarker(line string) bool {
69
+
if strings.HasSuffix(line, " differ\n") {
70
+
return strings.HasPrefix(line, "Binary files ") || strings.HasPrefix(line, "Files ")
71
+
}
72
+
return false
66
73
}
67
74
68
75
func (p *parser) ParseBinaryFragmentHeader() (*BinaryFragment, error) {
+10
gitdiff/binary_test.go
+10
gitdiff/binary_test.go
···
25
25
IsBinary: true,
26
26
HasData: false,
27
27
},
28
+
"binaryFileNoPatchPaths": {
29
+
Input: "Binary files a/foo.bin and b/foo.bin differ\n",
30
+
IsBinary: true,
31
+
HasData: false,
32
+
},
33
+
"fileNoPatch": {
34
+
Input: "Files differ\n",
35
+
IsBinary: true,
36
+
HasData: false,
37
+
},
28
38
"textFile": {
29
39
Input: "@@ -10,14 +22,31 @@\n",
30
40
IsBinary: false,
+3
-3
gitdiff/file_header.go
+3
-3
gitdiff/file_header.go
···
57
57
return nil, "", err
58
58
}
59
59
}
60
-
return nil, "", nil
60
+
return nil, preamble.String(), nil
61
61
}
62
62
63
63
func (p *parser) ParseGitFileHeader() (*File, error) {
···
324
324
}
325
325
326
326
func parseGitHeaderOldMode(f *File, line, defaultName string) (err error) {
327
-
f.OldMode, err = parseMode(line)
327
+
f.OldMode, err = parseMode(strings.TrimSpace(line))
328
328
return
329
329
}
330
330
331
331
func parseGitHeaderNewMode(f *File, line, defaultName string) (err error) {
332
-
f.NewMode, err = parseMode(line)
332
+
f.NewMode, err = parseMode(strings.TrimSpace(line))
333
333
return
334
334
}
335
335
+21
gitdiff/file_header_test.go
+21
gitdiff/file_header_test.go
···
486
486
OldMode: os.FileMode(0100644),
487
487
},
488
488
},
489
+
"oldModeWithTrailingSpace": {
490
+
Line: "old mode 100644\r\n",
491
+
OutputFile: &File{
492
+
OldMode: os.FileMode(0100644),
493
+
},
494
+
},
489
495
"invalidOldMode": {
490
496
Line: "old mode rw\n",
491
497
Err: true,
···
496
502
NewMode: os.FileMode(0100755),
497
503
},
498
504
},
505
+
"newModeWithTrailingSpace": {
506
+
Line: "new mode 100755\r\n",
507
+
OutputFile: &File{
508
+
NewMode: os.FileMode(0100755),
509
+
},
510
+
},
499
511
"invalidNewMode": {
500
512
Line: "new mode rwx\n",
501
513
Err: true,
···
511
523
},
512
524
"newFileMode": {
513
525
Line: "new file mode 100755\n",
526
+
DefaultName: "dir/file.txt",
527
+
OutputFile: &File{
528
+
NewName: "dir/file.txt",
529
+
NewMode: os.FileMode(0100755),
530
+
IsNew: true,
531
+
},
532
+
},
533
+
"newFileModeWithTrailingSpace": {
534
+
Line: "new file mode 100755\r\n",
514
535
DefaultName: "dir/file.txt",
515
536
OutputFile: &File{
516
537
NewName: "dir/file.txt",
+281
gitdiff/format.go
+281
gitdiff/format.go
···
1
+
package gitdiff
2
+
3
+
import (
4
+
"bytes"
5
+
"compress/zlib"
6
+
"fmt"
7
+
"io"
8
+
"strconv"
9
+
)
10
+
11
+
type formatter struct {
12
+
w io.Writer
13
+
err error
14
+
}
15
+
16
+
func newFormatter(w io.Writer) *formatter {
17
+
return &formatter{w: w}
18
+
}
19
+
20
+
func (fm *formatter) Write(p []byte) (int, error) {
21
+
if fm.err != nil {
22
+
return len(p), nil
23
+
}
24
+
if _, err := fm.w.Write(p); err != nil {
25
+
fm.err = err
26
+
}
27
+
return len(p), nil
28
+
}
29
+
30
+
func (fm *formatter) WriteString(s string) (int, error) {
31
+
fm.Write([]byte(s))
32
+
return len(s), nil
33
+
}
34
+
35
+
func (fm *formatter) WriteByte(c byte) error {
36
+
fm.Write([]byte{c})
37
+
return nil
38
+
}
39
+
40
+
func (fm *formatter) WriteQuotedName(s string) {
41
+
qpos := 0
42
+
for i := 0; i < len(s); i++ {
43
+
ch := s[i]
44
+
if q, quoted := quoteByte(ch); quoted {
45
+
if qpos == 0 {
46
+
fm.WriteByte('"')
47
+
}
48
+
fm.WriteString(s[qpos:i])
49
+
fm.Write(q)
50
+
qpos = i + 1
51
+
}
52
+
}
53
+
fm.WriteString(s[qpos:])
54
+
if qpos > 0 {
55
+
fm.WriteByte('"')
56
+
}
57
+
}
58
+
59
+
var quoteEscapeTable = map[byte]byte{
60
+
'\a': 'a',
61
+
'\b': 'b',
62
+
'\t': 't',
63
+
'\n': 'n',
64
+
'\v': 'v',
65
+
'\f': 'f',
66
+
'\r': 'r',
67
+
'"': '"',
68
+
'\\': '\\',
69
+
}
70
+
71
+
func quoteByte(b byte) ([]byte, bool) {
72
+
if q, ok := quoteEscapeTable[b]; ok {
73
+
return []byte{'\\', q}, true
74
+
}
75
+
if b < 0x20 || b >= 0x7F {
76
+
return []byte{
77
+
'\\',
78
+
'0' + (b>>6)&0o3,
79
+
'0' + (b>>3)&0o7,
80
+
'0' + (b>>0)&0o7,
81
+
}, true
82
+
}
83
+
return nil, false
84
+
}
85
+
86
+
func (fm *formatter) FormatFile(f *File) {
87
+
fm.WriteString("diff --git ")
88
+
89
+
var aName, bName string
90
+
switch {
91
+
case f.OldName == "":
92
+
aName = f.NewName
93
+
bName = f.NewName
94
+
95
+
case f.NewName == "":
96
+
aName = f.OldName
97
+
bName = f.OldName
98
+
99
+
default:
100
+
aName = f.OldName
101
+
bName = f.NewName
102
+
}
103
+
104
+
fm.WriteQuotedName("a/" + aName)
105
+
fm.WriteByte(' ')
106
+
fm.WriteQuotedName("b/" + bName)
107
+
fm.WriteByte('\n')
108
+
109
+
if f.OldMode != 0 {
110
+
if f.IsDelete {
111
+
fmt.Fprintf(fm, "deleted file mode %o\n", f.OldMode)
112
+
} else if f.NewMode != 0 {
113
+
fmt.Fprintf(fm, "old mode %o\n", f.OldMode)
114
+
}
115
+
}
116
+
117
+
if f.NewMode != 0 {
118
+
if f.IsNew {
119
+
fmt.Fprintf(fm, "new file mode %o\n", f.NewMode)
120
+
} else if f.OldMode != 0 {
121
+
fmt.Fprintf(fm, "new mode %o\n", f.NewMode)
122
+
}
123
+
}
124
+
125
+
if f.Score > 0 {
126
+
if f.IsCopy || f.IsRename {
127
+
fmt.Fprintf(fm, "similarity index %d%%\n", f.Score)
128
+
} else {
129
+
fmt.Fprintf(fm, "dissimilarity index %d%%\n", f.Score)
130
+
}
131
+
}
132
+
133
+
if f.IsCopy {
134
+
if f.OldName != "" {
135
+
fm.WriteString("copy from ")
136
+
fm.WriteQuotedName(f.OldName)
137
+
fm.WriteByte('\n')
138
+
}
139
+
if f.NewName != "" {
140
+
fm.WriteString("copy to ")
141
+
fm.WriteQuotedName(f.NewName)
142
+
fm.WriteByte('\n')
143
+
}
144
+
}
145
+
146
+
if f.IsRename {
147
+
if f.OldName != "" {
148
+
fm.WriteString("rename from ")
149
+
fm.WriteQuotedName(f.OldName)
150
+
fm.WriteByte('\n')
151
+
}
152
+
if f.NewName != "" {
153
+
fm.WriteString("rename to ")
154
+
fm.WriteQuotedName(f.NewName)
155
+
fm.WriteByte('\n')
156
+
}
157
+
}
158
+
159
+
if f.OldOIDPrefix != "" && f.NewOIDPrefix != "" {
160
+
fmt.Fprintf(fm, "index %s..%s", f.OldOIDPrefix, f.NewOIDPrefix)
161
+
162
+
// Mode is only included on the index line when it is not changing
163
+
if f.OldMode != 0 && ((f.NewMode == 0 && !f.IsDelete) || f.OldMode == f.NewMode) {
164
+
fmt.Fprintf(fm, " %o", f.OldMode)
165
+
}
166
+
167
+
fm.WriteByte('\n')
168
+
}
169
+
170
+
if f.IsBinary {
171
+
if f.BinaryFragment == nil {
172
+
fm.WriteString("Binary files ")
173
+
fm.WriteQuotedName("a/" + aName)
174
+
fm.WriteString(" and ")
175
+
fm.WriteQuotedName("b/" + bName)
176
+
fm.WriteString(" differ\n")
177
+
} else {
178
+
fm.WriteString("GIT binary patch\n")
179
+
fm.FormatBinaryFragment(f.BinaryFragment)
180
+
if f.ReverseBinaryFragment != nil {
181
+
fm.FormatBinaryFragment(f.ReverseBinaryFragment)
182
+
}
183
+
}
184
+
}
185
+
186
+
// The "---" and "+++" lines only appear for text patches with fragments
187
+
if len(f.TextFragments) > 0 {
188
+
fm.WriteString("--- ")
189
+
if f.OldName == "" {
190
+
fm.WriteString("/dev/null")
191
+
} else {
192
+
fm.WriteQuotedName("a/" + f.OldName)
193
+
}
194
+
fm.WriteByte('\n')
195
+
196
+
fm.WriteString("+++ ")
197
+
if f.NewName == "" {
198
+
fm.WriteString("/dev/null")
199
+
} else {
200
+
fm.WriteQuotedName("b/" + f.NewName)
201
+
}
202
+
fm.WriteByte('\n')
203
+
204
+
for _, frag := range f.TextFragments {
205
+
fm.FormatTextFragment(frag)
206
+
}
207
+
}
208
+
}
209
+
210
+
func (fm *formatter) FormatTextFragment(f *TextFragment) {
211
+
fm.FormatTextFragmentHeader(f)
212
+
fm.WriteByte('\n')
213
+
214
+
for _, line := range f.Lines {
215
+
fm.WriteString(line.Op.String())
216
+
fm.WriteString(line.Line)
217
+
if line.NoEOL() {
218
+
fm.WriteString("\n\\ No newline at end of file\n")
219
+
}
220
+
}
221
+
}
222
+
223
+
func (fm *formatter) FormatTextFragmentHeader(f *TextFragment) {
224
+
fmt.Fprintf(fm, "@@ -%d,%d +%d,%d @@", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines)
225
+
if f.Comment != "" {
226
+
fm.WriteByte(' ')
227
+
fm.WriteString(f.Comment)
228
+
}
229
+
}
230
+
231
+
func (fm *formatter) FormatBinaryFragment(f *BinaryFragment) {
232
+
const (
233
+
maxBytesPerLine = 52
234
+
)
235
+
236
+
switch f.Method {
237
+
case BinaryPatchDelta:
238
+
fm.WriteString("delta ")
239
+
case BinaryPatchLiteral:
240
+
fm.WriteString("literal ")
241
+
}
242
+
fm.Write(strconv.AppendInt(nil, f.Size, 10))
243
+
fm.WriteByte('\n')
244
+
245
+
data := deflateBinaryChunk(f.Data)
246
+
n := (len(data) / maxBytesPerLine) * maxBytesPerLine
247
+
248
+
buf := make([]byte, base85Len(maxBytesPerLine))
249
+
for i := 0; i < n; i += maxBytesPerLine {
250
+
base85Encode(buf, data[i:i+maxBytesPerLine])
251
+
fm.WriteByte('z')
252
+
fm.Write(buf)
253
+
fm.WriteByte('\n')
254
+
}
255
+
if remainder := len(data) - n; remainder > 0 {
256
+
buf = buf[0:base85Len(remainder)]
257
+
258
+
sizeChar := byte(remainder)
259
+
if remainder <= 26 {
260
+
sizeChar = 'A' + sizeChar - 1
261
+
} else {
262
+
sizeChar = 'a' + sizeChar - 27
263
+
}
264
+
265
+
base85Encode(buf, data[n:])
266
+
fm.WriteByte(sizeChar)
267
+
fm.Write(buf)
268
+
fm.WriteByte('\n')
269
+
}
270
+
fm.WriteByte('\n')
271
+
}
272
+
273
+
func deflateBinaryChunk(data []byte) []byte {
274
+
var b bytes.Buffer
275
+
276
+
zw := zlib.NewWriter(&b)
277
+
_, _ = zw.Write(data)
278
+
_ = zw.Close()
279
+
280
+
return b.Bytes()
281
+
}
+157
gitdiff/format_roundtrip_test.go
+157
gitdiff/format_roundtrip_test.go
···
1
+
package gitdiff
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"os"
7
+
"path/filepath"
8
+
"slices"
9
+
"testing"
10
+
)
11
+
12
+
func TestFormatRoundtrip(t *testing.T) {
13
+
patches := []struct {
14
+
File string
15
+
SkipTextCompare bool
16
+
}{
17
+
{File: "copy.patch"},
18
+
{File: "copy_modify.patch"},
19
+
{File: "delete.patch"},
20
+
{File: "mode.patch"},
21
+
{File: "mode_modify.patch"},
22
+
{File: "modify.patch"},
23
+
{File: "new.patch"},
24
+
{File: "new_empty.patch"},
25
+
{File: "new_mode.patch"},
26
+
{File: "rename.patch"},
27
+
{File: "rename_modify.patch"},
28
+
29
+
// Due to differences between Go's 'encoding/zlib' package and the zlib
30
+
// C library, binary patches cannot be compared directly as the patch
31
+
// data is slightly different when re-encoded by Go.
32
+
{File: "binary_modify.patch", SkipTextCompare: true},
33
+
{File: "binary_new.patch", SkipTextCompare: true},
34
+
{File: "binary_modify_nodata.patch"},
35
+
}
36
+
37
+
for _, patch := range patches {
38
+
t.Run(patch.File, func(t *testing.T) {
39
+
b, err := os.ReadFile(filepath.Join("testdata", "string", patch.File))
40
+
if err != nil {
41
+
t.Fatalf("failed to read patch: %v", err)
42
+
}
43
+
44
+
original := assertParseSingleFile(t, b, "patch")
45
+
str := original.String()
46
+
47
+
if !patch.SkipTextCompare {
48
+
if string(b) != str {
49
+
t.Errorf("incorrect patch text\nexpected: %q\n actual: %q\n", string(b), str)
50
+
}
51
+
}
52
+
53
+
reparsed := assertParseSingleFile(t, []byte(str), "formatted patch")
54
+
assertFilesEqual(t, original, reparsed)
55
+
})
56
+
}
57
+
}
58
+
59
+
func assertParseSingleFile(t *testing.T, b []byte, kind string) *File {
60
+
files, _, err := Parse(bytes.NewReader(b))
61
+
if err != nil {
62
+
t.Fatalf("failed to parse %s: %v", kind, err)
63
+
}
64
+
if len(files) != 1 {
65
+
t.Fatalf("expected %s to contain a single files, but found %d", kind, len(files))
66
+
}
67
+
return files[0]
68
+
}
69
+
70
+
func assertFilesEqual(t *testing.T, expected, actual *File) {
71
+
assertEqual(t, expected.OldName, actual.OldName, "OldName")
72
+
assertEqual(t, expected.NewName, actual.NewName, "NewName")
73
+
74
+
assertEqual(t, expected.IsNew, actual.IsNew, "IsNew")
75
+
assertEqual(t, expected.IsDelete, actual.IsDelete, "IsDelete")
76
+
assertEqual(t, expected.IsCopy, actual.IsCopy, "IsCopy")
77
+
assertEqual(t, expected.IsRename, actual.IsRename, "IsRename")
78
+
79
+
assertEqual(t, expected.OldMode, actual.OldMode, "OldMode")
80
+
assertEqual(t, expected.NewMode, actual.NewMode, "NewMode")
81
+
82
+
assertEqual(t, expected.OldOIDPrefix, actual.OldOIDPrefix, "OldOIDPrefix")
83
+
assertEqual(t, expected.NewOIDPrefix, actual.NewOIDPrefix, "NewOIDPrefix")
84
+
assertEqual(t, expected.Score, actual.Score, "Score")
85
+
86
+
if len(expected.TextFragments) == len(actual.TextFragments) {
87
+
for i := range expected.TextFragments {
88
+
prefix := fmt.Sprintf("TextFragments[%d].", i)
89
+
ef := expected.TextFragments[i]
90
+
af := actual.TextFragments[i]
91
+
92
+
assertEqual(t, ef.Comment, af.Comment, prefix+"Comment")
93
+
94
+
assertEqual(t, ef.OldPosition, af.OldPosition, prefix+"OldPosition")
95
+
assertEqual(t, ef.OldLines, af.OldLines, prefix+"OldLines")
96
+
97
+
assertEqual(t, ef.NewPosition, af.NewPosition, prefix+"NewPosition")
98
+
assertEqual(t, ef.NewLines, af.NewLines, prefix+"NewLines")
99
+
100
+
assertEqual(t, ef.LinesAdded, af.LinesAdded, prefix+"LinesAdded")
101
+
assertEqual(t, ef.LinesDeleted, af.LinesDeleted, prefix+"LinesDeleted")
102
+
103
+
assertEqual(t, ef.LeadingContext, af.LeadingContext, prefix+"LeadingContext")
104
+
assertEqual(t, ef.TrailingContext, af.TrailingContext, prefix+"TrailingContext")
105
+
106
+
if !slices.Equal(ef.Lines, af.Lines) {
107
+
t.Errorf("%sLines: expected %#v, actual %#v", prefix, ef.Lines, af.Lines)
108
+
}
109
+
}
110
+
} else {
111
+
t.Errorf("TextFragments: expected length %d, actual length %d", len(expected.TextFragments), len(actual.TextFragments))
112
+
}
113
+
114
+
assertEqual(t, expected.IsBinary, actual.IsBinary, "IsBinary")
115
+
116
+
if expected.BinaryFragment != nil {
117
+
if actual.BinaryFragment == nil {
118
+
t.Errorf("BinaryFragment: expected non-nil, actual is nil")
119
+
} else {
120
+
ef := expected.BinaryFragment
121
+
af := expected.BinaryFragment
122
+
123
+
assertEqual(t, ef.Method, af.Method, "BinaryFragment.Method")
124
+
assertEqual(t, ef.Size, af.Size, "BinaryFragment.Size")
125
+
126
+
if !slices.Equal(ef.Data, af.Data) {
127
+
t.Errorf("BinaryFragment.Data: expected %#v, actual %#v", ef.Data, af.Data)
128
+
}
129
+
}
130
+
} else if actual.BinaryFragment != nil {
131
+
t.Errorf("BinaryFragment: expected nil, actual is non-nil")
132
+
}
133
+
134
+
if expected.ReverseBinaryFragment != nil {
135
+
if actual.ReverseBinaryFragment == nil {
136
+
t.Errorf("ReverseBinaryFragment: expected non-nil, actual is nil")
137
+
} else {
138
+
ef := expected.ReverseBinaryFragment
139
+
af := expected.ReverseBinaryFragment
140
+
141
+
assertEqual(t, ef.Method, af.Method, "ReverseBinaryFragment.Method")
142
+
assertEqual(t, ef.Size, af.Size, "ReverseBinaryFragment.Size")
143
+
144
+
if !slices.Equal(ef.Data, af.Data) {
145
+
t.Errorf("ReverseBinaryFragment.Data: expected %#v, actual %#v", ef.Data, af.Data)
146
+
}
147
+
}
148
+
} else if actual.ReverseBinaryFragment != nil {
149
+
t.Errorf("ReverseBinaryFragment: expected nil, actual is non-nil")
150
+
}
151
+
}
152
+
153
+
func assertEqual[T comparable](t *testing.T, expected, actual T, name string) {
154
+
if expected != actual {
155
+
t.Errorf("%s: expected %#v, actual %#v", name, expected, actual)
156
+
}
157
+
}
+28
gitdiff/format_test.go
+28
gitdiff/format_test.go
···
1
+
package gitdiff
2
+
3
+
import (
4
+
"strings"
5
+
"testing"
6
+
)
7
+
8
+
func TestFormatter_WriteQuotedName(t *testing.T) {
9
+
tests := []struct {
10
+
Input string
11
+
Expected string
12
+
}{
13
+
{"noquotes.txt", `noquotes.txt`},
14
+
{"no quotes.txt", `no quotes.txt`},
15
+
{"new\nline", `"new\nline"`},
16
+
{"escape\x1B null\x00", `"escape\033 null\000"`},
17
+
{"snowman \u2603 snowman", `"snowman \342\230\203 snowman"`},
18
+
{"\"already quoted\"", `"\"already quoted\""`},
19
+
}
20
+
21
+
for _, test := range tests {
22
+
var b strings.Builder
23
+
newFormatter(&b).WriteQuotedName(test.Input)
24
+
if b.String() != test.Expected {
25
+
t.Errorf("expected %q, got %q", test.Expected, b.String())
26
+
}
27
+
}
28
+
}
+33
-2
gitdiff/gitdiff.go
+33
-2
gitdiff/gitdiff.go
···
4
4
"errors"
5
5
"fmt"
6
6
"os"
7
+
"strings"
7
8
)
8
9
9
10
// File describes changes to a single file. It can be either a text file or a
···
38
39
ReverseBinaryFragment *BinaryFragment
39
40
}
40
41
42
+
// String returns a git diff representation of this file. The value can be
43
+
// parsed by this library to obtain the same File, but may not be the same as
44
+
// the original input.
45
+
func (f *File) String() string {
46
+
var diff strings.Builder
47
+
newFormatter(&diff).FormatFile(f)
48
+
return diff.String()
49
+
}
50
+
41
51
// TextFragment describes changed lines starting at a specific line in a text file.
42
52
type TextFragment struct {
43
53
Comment string
···
57
67
Lines []Line
58
68
}
59
69
60
-
// Header returns the canonical header of this fragment.
70
+
// String returns a git diff format of this fragment. See [File.String] for
71
+
// more details on this format.
72
+
func (f *TextFragment) String() string {
73
+
var diff strings.Builder
74
+
newFormatter(&diff).FormatTextFragment(f)
75
+
return diff.String()
76
+
}
77
+
78
+
// Header returns a git diff header of this fragment. See [File.String] for
79
+
// more details on this format.
61
80
func (f *TextFragment) Header() string {
62
-
return fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines, f.Comment)
81
+
var hdr strings.Builder
82
+
newFormatter(&hdr).FormatTextFragmentHeader(f)
83
+
return hdr.String()
63
84
}
64
85
65
86
// Validate checks that the fragment is self-consistent and appliable. Validate
···
197
218
// BinaryPatchLiteral indicates the data is the exact file content
198
219
BinaryPatchLiteral
199
220
)
221
+
222
+
// String returns a git diff format of this fragment. Due to differences in
223
+
// zlib implementation between Go and Git, encoded binary data in the result
224
+
// will likely differ from what Git produces for the same input. See
225
+
// [File.String] for more details on this format.
226
+
func (f *BinaryFragment) String() string {
227
+
var diff strings.Builder
228
+
newFormatter(&diff).FormatBinaryFragment(f)
229
+
return diff.String()
230
+
}
+7
-3
gitdiff/parser.go
+7
-3
gitdiff/parser.go
···
12
12
// Parse parses a patch with changes to one or more files. Any content before
13
13
// the first file is returned as the second value. If an error occurs while
14
14
// parsing, it returns all files parsed before the error.
15
+
//
16
+
// Parse expects to receive a single patch. If the input may contain multiple
17
+
// patches (for example, if it is an mbox file), callers should split it into
18
+
// individual patches and call Parse on each one.
15
19
func Parse(r io.Reader) ([]*File, string, error) {
16
20
p := newParser(r)
17
21
···
29
33
if err != nil {
30
34
return files, preamble, err
31
35
}
36
+
if len(files) == 0 {
37
+
preamble = pre
38
+
}
32
39
if file == nil {
33
40
break
34
41
}
···
46
53
}
47
54
}
48
55
49
-
if len(files) == 0 {
50
-
preamble = pre
51
-
}
52
56
files = append(files, file)
53
57
}
54
58
+16
-2
gitdiff/parser_test.go
+16
-2
gitdiff/parser_test.go
···
281
281
--- could this be a header?
282
282
nope, it's just some dashes
283
283
`,
284
-
Output: nil,
285
-
Preamble: "",
284
+
Output: nil,
285
+
Preamble: `
286
+
this is a line
287
+
this is another line
288
+
--- could this be a header?
289
+
nope, it's just some dashes
290
+
`,
286
291
},
287
292
"detatchedFragmentLike": {
288
293
Input: `
···
290
295
@@ -1,3 +1,4 ~1,5 @@
291
296
`,
292
297
Output: nil,
298
+
Preamble: `
299
+
a wild fragment appears?
300
+
@@ -1,3 +1,4 ~1,5 @@
301
+
`,
293
302
},
294
303
"detatchedFragment": {
295
304
Input: `
···
425
434
},
426
435
},
427
436
Preamble: textPreamble,
437
+
},
438
+
"noFiles": {
439
+
InputFile: "testdata/no_files.patch",
440
+
Output: nil,
441
+
Preamble: textPreamble,
428
442
},
429
443
"newBinaryFile": {
430
444
InputFile: "testdata/new_binary_file.patch",
+19
-58
gitdiff/patch_header.go
+19
-58
gitdiff/patch_header.go
···
52
52
// line, that line will be removed and everything after it will be
53
53
// placed in BodyAppendix.
54
54
BodyAppendix string
55
+
56
+
// All headers completely unparsed
57
+
RawHeaders map[string][]string
55
58
}
56
59
57
60
// Message returns the commit message for the header. The message consists of
···
68
71
return msg.String()
69
72
}
70
73
71
-
// PatchIdentity identifies a person who authored or committed a patch.
72
-
type PatchIdentity struct {
73
-
Name string
74
-
Email string
75
-
}
76
-
77
-
func (i PatchIdentity) String() string {
78
-
name := i.Name
79
-
if name == "" {
80
-
name = `""`
81
-
}
82
-
return fmt.Sprintf("%s <%s>", name, i.Email)
83
-
}
84
-
85
-
// ParsePatchIdentity parses a patch identity string. A valid string contains a
86
-
// non-empty name followed by an email address in angle brackets. Like Git,
87
-
// ParsePatchIdentity does not require that the email address is valid or
88
-
// properly formatted, only that it is non-empty. The name must not contain a
89
-
// left angle bracket, '<', and the email address must not contain a right
90
-
// angle bracket, '>'.
91
-
func ParsePatchIdentity(s string) (PatchIdentity, error) {
92
-
var emailStart, emailEnd int
93
-
for i, c := range s {
94
-
if c == '<' && emailStart == 0 {
95
-
emailStart = i + 1
96
-
}
97
-
if c == '>' && emailStart > 0 {
98
-
emailEnd = i
99
-
break
100
-
}
101
-
}
102
-
if emailStart > 0 && emailEnd == 0 {
103
-
return PatchIdentity{}, fmt.Errorf("invalid identity string: unclosed email section: %s", s)
104
-
}
105
-
106
-
var name, email string
107
-
if emailStart > 0 {
108
-
name = strings.TrimSpace(s[:emailStart-1])
109
-
}
110
-
if emailStart > 0 && emailEnd > 0 {
111
-
email = strings.TrimSpace(s[emailStart:emailEnd])
112
-
}
113
-
if name == "" || email == "" {
114
-
return PatchIdentity{}, fmt.Errorf("invalid identity string: %s", s)
115
-
}
116
-
117
-
return PatchIdentity{Name: name, Email: email}, nil
118
-
}
119
-
120
74
// ParsePatchDate parses a patch date string. It returns the parsed time or an
121
75
// error if s has an unknown format. ParsePatchDate supports the iso, rfc,
122
76
// short, raw, unix, and default formats (with local variants) used by the
···
286
240
break
287
241
}
288
242
243
+
items := strings.SplitN(line, ":", 2)
244
+
245
+
// we have "key: value"
246
+
if len(items) == 2 {
247
+
key := items[0]
248
+
val := items[1]
249
+
h.RawHeaders[key] = append(h.RawHeaders[key], val)
250
+
}
251
+
289
252
switch {
290
253
case strings.HasPrefix(line, authorPrefix):
291
254
u, err := ParsePatchIdentity(line[len(authorPrefix):])
···
410
373
}
411
374
412
375
h := &PatchHeader{}
376
+
h.RawHeaders = msg.Header
413
377
414
378
if strings.HasPrefix(mailLine, mailHeaderPrefix) {
415
379
mailLine = strings.TrimPrefix(mailLine, mailHeaderPrefix)
···
418
382
}
419
383
}
420
384
421
-
addrs, err := msg.Header.AddressList("From")
422
-
if err != nil && !errors.Is(err, mail.ErrHeaderNotPresent) {
423
-
return nil, err
424
-
}
425
-
if len(addrs) > 0 {
426
-
addr := addrs[0]
427
-
if addr.Name == "" {
428
-
addr.Name = addr.Address
385
+
from := msg.Header.Get("From")
386
+
if from != "" {
387
+
u, err := ParsePatchIdentity(from)
388
+
if err != nil {
389
+
return nil, err
429
390
}
430
-
h.Author = &PatchIdentity{Name: addr.Name, Email: addr.Address}
391
+
h.Author = &u
431
392
}
432
393
433
394
date := msg.Header.Get("Date")
+22
-59
gitdiff/patch_header_test.go
+22
-59
gitdiff/patch_header_test.go
···
5
5
"time"
6
6
)
7
7
8
-
func TestParsePatchIdentity(t *testing.T) {
9
-
tests := map[string]struct {
10
-
Input string
11
-
Output PatchIdentity
12
-
Err interface{}
13
-
}{
14
-
"simple": {
15
-
Input: "Morton Haypenny <mhaypenny@example.com>",
16
-
Output: PatchIdentity{
17
-
Name: "Morton Haypenny",
18
-
Email: "mhaypenny@example.com",
19
-
},
20
-
},
21
-
"extraWhitespace": {
22
-
Input: " Morton Haypenny <mhaypenny@example.com > ",
23
-
Output: PatchIdentity{
24
-
Name: "Morton Haypenny",
25
-
Email: "mhaypenny@example.com",
26
-
},
27
-
},
28
-
"trailingCharacters": {
29
-
Input: "Morton Haypenny <mhaypenny@example.com> unrelated garbage",
30
-
Output: PatchIdentity{
31
-
Name: "Morton Haypenny",
32
-
Email: "mhaypenny@example.com",
33
-
},
34
-
},
35
-
"missingName": {
36
-
Input: "<mhaypenny@example.com>",
37
-
Err: "invalid identity",
38
-
},
39
-
"missingEmail": {
40
-
Input: "Morton Haypenny",
41
-
Err: "invalid identity",
42
-
},
43
-
"unclosedEmail": {
44
-
Input: "Morton Haypenny <mhaypenny@example.com",
45
-
Err: "unclosed email",
46
-
},
47
-
}
48
-
49
-
for name, test := range tests {
50
-
t.Run(name, func(t *testing.T) {
51
-
id, err := ParsePatchIdentity(test.Input)
52
-
if test.Err != nil {
53
-
assertError(t, test.Err, err, "parsing identity")
54
-
return
55
-
}
56
-
if err != nil {
57
-
t.Fatalf("unexpected error parsing identity: %v", err)
58
-
}
59
-
60
-
if test.Output != id {
61
-
t.Errorf("incorrect identity: expected %#v, actual %#v", test.Output, id)
62
-
}
63
-
})
64
-
}
65
-
}
66
-
67
8
func TestParsePatchDate(t *testing.T) {
68
9
expected := time.Date(2020, 4, 9, 8, 7, 6, 0, time.UTC)
69
10
···
328
269
Author: expectedIdentity,
329
270
AuthorDate: expectedDate,
330
271
Title: expectedEmojiMultiLineTitle,
272
+
Body: expectedBody,
273
+
},
274
+
},
275
+
"mailboxRFC5322SpecialCharacters": {
276
+
Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001
277
+
From: "dependabot[bot]" <12345+dependabot[bot]@users.noreply.github.com>
278
+
Date: Sat, 11 Apr 2020 15:21:23 -0700
279
+
Subject: [PATCH] A sample commit to test header parsing
280
+
281
+
The medium format shows the body, which
282
+
may wrap on to multiple lines.
283
+
284
+
Another body line.
285
+
`,
286
+
Header: PatchHeader{
287
+
SHA: expectedSHA,
288
+
Author: &PatchIdentity{
289
+
Name: "dependabot[bot]",
290
+
Email: "12345+dependabot[bot]@users.noreply.github.com",
291
+
},
292
+
AuthorDate: expectedDate,
293
+
Title: expectedTitle,
331
294
Body: expectedBody,
332
295
},
333
296
},
+166
gitdiff/patch_identity.go
+166
gitdiff/patch_identity.go
···
1
+
package gitdiff
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
)
7
+
8
+
// PatchIdentity identifies a person who authored or committed a patch.
9
+
type PatchIdentity struct {
10
+
Name string
11
+
Email string
12
+
}
13
+
14
+
func (i PatchIdentity) String() string {
15
+
name := i.Name
16
+
if name == "" {
17
+
name = `""`
18
+
}
19
+
return fmt.Sprintf("%s <%s>", name, i.Email)
20
+
}
21
+
22
+
// ParsePatchIdentity parses a patch identity string. A patch identity contains
23
+
// an email address and an optional name in [RFC 5322] format. This is either a
24
+
// plain email adddress or a name followed by an address in angle brackets:
25
+
//
26
+
// author@example.com
27
+
// Author Name <author@example.com>
28
+
//
29
+
// If the input is not one of these formats, ParsePatchIdentity applies a
30
+
// heuristic to separate the name and email portions. If both the name and
31
+
// email are missing or empty, ParsePatchIdentity returns an error. It
32
+
// otherwise does not validate the result.
33
+
//
34
+
// [RFC 5322]: https://datatracker.ietf.org/doc/html/rfc5322
35
+
func ParsePatchIdentity(s string) (PatchIdentity, error) {
36
+
s = normalizeSpace(s)
37
+
s = unquotePairs(s)
38
+
39
+
var name, email string
40
+
if at := strings.IndexByte(s, '@'); at >= 0 {
41
+
start, end := at, at
42
+
for start >= 0 && !isRFC5332Space(s[start]) && s[start] != '<' {
43
+
start--
44
+
}
45
+
for end < len(s) && !isRFC5332Space(s[end]) && s[end] != '>' {
46
+
end++
47
+
}
48
+
email = s[start+1 : end]
49
+
50
+
// Adjust the boundaries so that we drop angle brackets, but keep
51
+
// spaces when removing the email to form the name.
52
+
if start < 0 || s[start] != '<' {
53
+
start++
54
+
}
55
+
if end >= len(s) || s[end] != '>' {
56
+
end--
57
+
}
58
+
name = s[:start] + s[end+1:]
59
+
} else {
60
+
start, end := 0, 0
61
+
for i := 0; i < len(s); i++ {
62
+
if s[i] == '<' && start == 0 {
63
+
start = i + 1
64
+
}
65
+
if s[i] == '>' && start > 0 {
66
+
end = i
67
+
break
68
+
}
69
+
}
70
+
if start > 0 && end >= start {
71
+
email = strings.TrimSpace(s[start:end])
72
+
name = s[:start-1]
73
+
}
74
+
}
75
+
76
+
// After extracting the email, the name might contain extra whitespace
77
+
// again and may be surrounded by comment characters. The git source gives
78
+
// these examples of when this can happen:
79
+
//
80
+
// "Name <email@domain>"
81
+
// "email@domain (Name)"
82
+
// "Name <email@domain> (Comment)"
83
+
//
84
+
name = normalizeSpace(name)
85
+
if strings.HasPrefix(name, "(") && strings.HasSuffix(name, ")") {
86
+
name = name[1 : len(name)-1]
87
+
}
88
+
name = strings.TrimSpace(name)
89
+
90
+
// If the name is empty or contains email-like characters, use the email
91
+
// instead (assuming one exists)
92
+
if name == "" || strings.ContainsAny(name, "@<>") {
93
+
name = email
94
+
}
95
+
96
+
if name == "" && email == "" {
97
+
return PatchIdentity{}, fmt.Errorf("invalid identity string %q", s)
98
+
}
99
+
return PatchIdentity{Name: name, Email: email}, nil
100
+
}
101
+
102
+
// unquotePairs process the RFC5322 tokens "quoted-string" and "comment" to
103
+
// remove any "quoted-pairs" (backslash-espaced characters). It also removes
104
+
// the quotes from any quoted strings, but leaves the comment delimiters.
105
+
func unquotePairs(s string) string {
106
+
quote := false
107
+
comments := 0
108
+
escaped := false
109
+
110
+
var out strings.Builder
111
+
for i := 0; i < len(s); i++ {
112
+
if escaped {
113
+
escaped = false
114
+
} else {
115
+
switch s[i] {
116
+
case '\\':
117
+
// quoted-pair is only allowed in quoted-string/comment
118
+
if quote || comments > 0 {
119
+
escaped = true
120
+
continue // drop '\' character
121
+
}
122
+
123
+
case '"':
124
+
if comments == 0 {
125
+
quote = !quote
126
+
continue // drop '"' character
127
+
}
128
+
129
+
case '(':
130
+
if !quote {
131
+
comments++
132
+
}
133
+
case ')':
134
+
if comments > 0 {
135
+
comments--
136
+
}
137
+
}
138
+
}
139
+
out.WriteByte(s[i])
140
+
}
141
+
return out.String()
142
+
}
143
+
144
+
// normalizeSpace trims leading and trailing whitespace from s and converts
145
+
// inner sequences of one or more whitespace characters to single spaces.
146
+
func normalizeSpace(s string) string {
147
+
var sb strings.Builder
148
+
for i := 0; i < len(s); i++ {
149
+
c := s[i]
150
+
if !isRFC5332Space(c) {
151
+
if sb.Len() > 0 && isRFC5332Space(s[i-1]) {
152
+
sb.WriteByte(' ')
153
+
}
154
+
sb.WriteByte(c)
155
+
}
156
+
}
157
+
return sb.String()
158
+
}
159
+
160
+
func isRFC5332Space(c byte) bool {
161
+
switch c {
162
+
case '\t', '\n', '\r', ' ':
163
+
return true
164
+
}
165
+
return false
166
+
}
+127
gitdiff/patch_identity_test.go
+127
gitdiff/patch_identity_test.go
···
1
+
package gitdiff
2
+
3
+
import (
4
+
"testing"
5
+
)
6
+
7
+
func TestParsePatchIdentity(t *testing.T) {
8
+
tests := map[string]struct {
9
+
Input string
10
+
Output PatchIdentity
11
+
Err interface{}
12
+
}{
13
+
"simple": {
14
+
Input: "Morton Haypenny <mhaypenny@example.com>",
15
+
Output: PatchIdentity{
16
+
Name: "Morton Haypenny",
17
+
Email: "mhaypenny@example.com",
18
+
},
19
+
},
20
+
"extraWhitespace": {
21
+
Input: "\t Morton Haypenny \r\n<mhaypenny@example.com> ",
22
+
Output: PatchIdentity{
23
+
Name: "Morton Haypenny",
24
+
Email: "mhaypenny@example.com",
25
+
},
26
+
},
27
+
"trailingCharacters": {
28
+
Input: "Morton Haypenny <mhaypenny@example.com> II",
29
+
Output: PatchIdentity{
30
+
Name: "Morton Haypenny II",
31
+
Email: "mhaypenny@example.com",
32
+
},
33
+
},
34
+
"onlyEmail": {
35
+
Input: "mhaypenny@example.com",
36
+
Output: PatchIdentity{
37
+
Name: "mhaypenny@example.com",
38
+
Email: "mhaypenny@example.com",
39
+
},
40
+
},
41
+
"onlyEmailInBrackets": {
42
+
Input: "<mhaypenny@example.com>",
43
+
Output: PatchIdentity{
44
+
Name: "mhaypenny@example.com",
45
+
Email: "mhaypenny@example.com",
46
+
},
47
+
},
48
+
"rfc5322SpecialCharacters": {
49
+
Input: `"dependabot[bot]" <12345+dependabot[bot]@users.noreply.github.com>`,
50
+
Output: PatchIdentity{
51
+
Name: "dependabot[bot]",
52
+
Email: "12345+dependabot[bot]@users.noreply.github.com",
53
+
},
54
+
},
55
+
"rfc5322QuotedPairs": {
56
+
Input: `"Morton \"Old-Timer\" Haypenny" <"mhaypenny\+[1900]"@example.com> (III \(PhD\))`,
57
+
Output: PatchIdentity{
58
+
Name: `Morton "Old-Timer" Haypenny (III (PhD))`,
59
+
Email: "mhaypenny+[1900]@example.com",
60
+
},
61
+
},
62
+
"rfc5322QuotedPairsOutOfContext": {
63
+
Input: `Morton \\Backslash Haypenny <mhaypenny@example.com>`,
64
+
Output: PatchIdentity{
65
+
Name: `Morton \\Backslash Haypenny`,
66
+
Email: "mhaypenny@example.com",
67
+
},
68
+
},
69
+
"emptyEmail": {
70
+
Input: "Morton Haypenny <>",
71
+
Output: PatchIdentity{
72
+
Name: "Morton Haypenny",
73
+
Email: "",
74
+
},
75
+
},
76
+
"unclosedEmail": {
77
+
Input: "Morton Haypenny <mhaypenny@example.com",
78
+
Output: PatchIdentity{
79
+
Name: "Morton Haypenny",
80
+
Email: "mhaypenny@example.com",
81
+
},
82
+
},
83
+
"bogusEmail": {
84
+
Input: "Morton Haypenny <mhaypenny>",
85
+
Output: PatchIdentity{
86
+
Name: "Morton Haypenny",
87
+
Email: "mhaypenny",
88
+
},
89
+
},
90
+
"bogusEmailWithWhitespace": {
91
+
Input: "Morton Haypenny < mhaypenny >",
92
+
Output: PatchIdentity{
93
+
Name: "Morton Haypenny",
94
+
Email: "mhaypenny",
95
+
},
96
+
},
97
+
"missingEmail": {
98
+
Input: "Morton Haypenny",
99
+
Err: "invalid identity",
100
+
},
101
+
"missingNameAndEmptyEmail": {
102
+
Input: "<>",
103
+
Err: "invalid identity",
104
+
},
105
+
"empty": {
106
+
Input: "",
107
+
Err: "invalid identity",
108
+
},
109
+
}
110
+
111
+
for name, test := range tests {
112
+
t.Run(name, func(t *testing.T) {
113
+
id, err := ParsePatchIdentity(test.Input)
114
+
if test.Err != nil {
115
+
assertError(t, test.Err, err, "parsing identity")
116
+
return
117
+
}
118
+
if err != nil {
119
+
t.Fatalf("unexpected error parsing identity: %v", err)
120
+
}
121
+
122
+
if test.Output != id {
123
+
t.Errorf("incorrect identity: expected %#v, actual %#v", test.Output, id)
124
+
}
125
+
})
126
+
}
127
+
}
+10
gitdiff/testdata/apply/text_fragment_change_end_eol.patch
+10
gitdiff/testdata/apply/text_fragment_change_end_eol.patch
···
1
+
diff --git a/gitdiff/testdata/apply/text_fragment_remove_last_eol.src b/gitdiff/testdata/apply/text_fragment_remove_last_eol.src
2
+
index a92d664..8cf2f17 100644
3
+
--- a/gitdiff/testdata/apply/text_fragment_remove_last_eol.src
4
+
+++ b/gitdiff/testdata/apply/text_fragment_remove_last_eol.src
5
+
@@ -1,3 +1,3 @@
6
+
line 1
7
+
line 2
8
+
-line 3
9
+
+line 3
10
+
\ No newline at end of file
+8
gitdiff/testdata/no_files.patch
+8
gitdiff/testdata/no_files.patch
+9
gitdiff/testdata/string/binary_modify.patch
+9
gitdiff/testdata/string/binary_modify.patch
+3
gitdiff/testdata/string/binary_modify_nodata.patch
+3
gitdiff/testdata/string/binary_modify_nodata.patch
+11
gitdiff/testdata/string/binary_new.patch
+11
gitdiff/testdata/string/binary_new.patch
···
1
+
diff --git a/file.bin b/file.bin
2
+
new file mode 100644
3
+
index 0000000000000000000000000000000000000000..a7f4d5d6975ec021016c02b6d58345ebf434f38c
4
+
GIT binary patch
5
+
literal 72
6
+
zcmV-O0Jr~td-`u6JcK&{KDK=<a#;v1^LR5&K)zQ0=Goz82(?nJ6_nD`f#8O9p}}{P
7
+
eiXim+rDI+BDadMQmMsO5Sw@;DbrCA+PamP;Ng_@F
8
+
9
+
literal 0
10
+
HcmV?d00001
11
+
+4
gitdiff/testdata/string/copy.patch
+4
gitdiff/testdata/string/copy.patch
+21
gitdiff/testdata/string/copy_modify.patch
+21
gitdiff/testdata/string/copy_modify.patch
···
1
+
diff --git a/file.txt b/numbers.txt
2
+
similarity index 57%
3
+
copy from file.txt
4
+
copy to numbers.txt
5
+
index c9e9e05..6c4a3e0 100644
6
+
--- a/file.txt
7
+
+++ b/numbers.txt
8
+
@@ -1,6 +1,6 @@
9
+
one
10
+
two
11
+
-three
12
+
+three three three
13
+
four
14
+
five
15
+
six
16
+
@@ -8,3 +8,5 @@ seven
17
+
eight
18
+
nine
19
+
ten
20
+
+eleven
21
+
+twelve
+16
gitdiff/testdata/string/delete.patch
+16
gitdiff/testdata/string/delete.patch
+3
gitdiff/testdata/string/mode.patch
+3
gitdiff/testdata/string/mode.patch
+10
gitdiff/testdata/string/mode_modify.patch
+10
gitdiff/testdata/string/mode_modify.patch
+16
gitdiff/testdata/string/modify.patch
+16
gitdiff/testdata/string/modify.patch
+16
gitdiff/testdata/string/new.patch
+16
gitdiff/testdata/string/new.patch
+3
gitdiff/testdata/string/new_empty.patch
+3
gitdiff/testdata/string/new_empty.patch
+16
gitdiff/testdata/string/new_mode.patch
+16
gitdiff/testdata/string/new_mode.patch
+4
gitdiff/testdata/string/rename.patch
+4
gitdiff/testdata/string/rename.patch
+18
gitdiff/testdata/string/rename_modify.patch
+18
gitdiff/testdata/string/rename_modify.patch
···
1
+
diff --git a/file.txt b/numbers.txt
2
+
similarity index 77%
3
+
rename from file.txt
4
+
rename to numbers.txt
5
+
index c9e9e05..a6b31d6 100644
6
+
--- a/file.txt
7
+
+++ b/numbers.txt
8
+
@@ -3,8 +3,9 @@ two
9
+
three
10
+
four
11
+
five
12
+
-six
13
+
+ six
14
+
seven
15
+
eight
16
+
nine
17
+
ten
18
+
+eleven