fork of go-gitdiff with jj support

Compare changes

Choose any two refs to compare.

Changed files
+5423 -169
.github
workflows
gitdiff
testdata
apply
string
+26
.github/workflows/go.yml
··· 1 + name: Go 2 + on: 3 + pull_request: 4 + push: 5 + branches: [master] 6 + 7 + jobs: 8 + verify: 9 + name: Verify 10 + runs-on: ubuntu-latest 11 + steps: 12 + - name: Set up Go 1.21 13 + uses: actions/setup-go@v5 14 + with: 15 + go-version: 1.21 16 + 17 + - name: Check out code into the Go module directory 18 + uses: actions/checkout@v4 19 + 20 + - name: Lint 21 + uses: golangci/golangci-lint-action@v7 22 + with: 23 + version: v2.0 24 + 25 + - name: Test 26 + run: go test -v ./...
+51
.golangci.yml
··· 1 + version: "2" 2 + 3 + run: 4 + tests: false 5 + 6 + linters: 7 + default: none 8 + enable: 9 + - errcheck 10 + - govet 11 + - ineffassign 12 + - misspell 13 + - revive 14 + - unconvert 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 43 + 44 + formatters: 45 + enable: 46 + - gofmt 47 + - goimports 48 + settings: 49 + goimports: 50 + local-prefixes: 51 + - github.com/bluekeyes/go-gitdiff
+43 -15
README.md
··· 1 1 # go-gitdiff 2 2 3 - [![GoDoc](https://godoc.org/github.com/bluekeyes/go-gitdiff/gitdiff?status.svg)](http://godoc.org/github.com/bluekeyes/go-gitdiff/gitdiff) [![Go Report Card](https://goreportcard.com/badge/github.com/bluekeyes/go-gitdiff)](https://goreportcard.com/report/github.com/bluekeyes/go-gitdiff) 3 + [![PkgGoDev](https://pkg.go.dev/badge/github.com/bluekeyes/go-gitdiff/gitdiff)](https://pkg.go.dev/github.com/bluekeyes/go-gitdiff/gitdiff) [![Go Report Card](https://goreportcard.com/badge/github.com/bluekeyes/go-gitdiff)](https://goreportcard.com/report/github.com/bluekeyes/go-gitdiff) 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 - It supports both standard line-oriented text patches and Git binary patches. 9 + It supports standard line-oriented text patches and Git binary patches, and 10 + aims to parse anything accepted by the `git apply` command. 10 11 11 12 ```golang 12 13 patch, err := os.Open("changes.patch") 13 14 if err != nil { 14 - log.Fatalf(err) 15 + log.Fatal(err) 15 16 } 16 17 18 + // files is a slice of *gitdiff.File describing the files changed in the patch 19 + // preamble is a string of the content of the patch before the first file 17 20 files, preamble, err := gitdiff.Parse(patch) 18 21 if err != nil { 19 - log.Fatalf(err) 22 + log.Fatal(err) 23 + } 24 + 25 + code, err := os.Open("code.go") 26 + if err != nil { 27 + log.Fatal(err) 20 28 } 21 29 22 - // files is a slice of *gitdiff.File describing the files changed in the patch 23 - // preamble is a string of the content of the patch before the first file 30 + // apply the changes in the patch to a source file 31 + var output bytes.Buffer 32 + if err := gitdiff.Apply(&output, code, files[0]); err != nil { 33 + log.Fatal(err) 34 + } 24 35 ``` 25 36 26 - ## Status 37 + ## Development Status 27 38 28 - In development, expect API changes. Patch parsing works, but has not been 29 - tested extensively against real-world patches. Patch application has not been 30 - implemented yet. 39 + The parsing API and types are complete and I expect will remain stable. Version 40 + 0.7.0 introduced a new apply API that may change more in the future to support 41 + non-strict patch application. 42 + 43 + Parsing and strict application are well-covered by unit tests and the library 44 + is used in a production application that parses and applies thousands of 45 + patches every day. However, the space of all possible patches is large, so 46 + there are likely undiscovered bugs. 47 + 48 + The parsing code has also had a modest amount of fuzz testing. 31 49 32 50 ## Why another git/unified diff parser? 33 51 ··· 35 53 [functionality][seletskiy] exist, so why did I write another? 36 54 37 55 1. No other packages I found support binary diffs, as generated with the 38 - `--binary` flag. This is the main reason for writing a new packages, as the 56 + `--binary` flag. This is the main reason for writing a new package, as the 39 57 format is pretty different from line-oriented diffs and is unique to Git. 40 58 41 - 2. Most other packages only parse patches, so you need another package to apply 42 - them (and if they do support applies, it is only for text files.) 59 + 2. Most other packages only parse patches, so you need additional code to apply 60 + them (and if applies are supported, it is only for text files.) 43 61 44 62 3. This package aims to accept anything that `git apply` accepts, and closely 45 63 follows the logic in [`apply.c`][apply.c]. 46 64 47 - 4. It seemed like a fun thing to write and a good way to learn more about Git. 65 + 4. It seemed like a fun project and a way to learn more about Git. 48 66 49 67 [sourcegraph]: https://github.com/sourcegraph/go-diff 50 68 [sergi]: https://github.com/sergi/go-diff ··· 60 78 61 79 - Numbers immediately followed by non-numeric characters 62 80 - Trailing characters on a line after valid or expected content 81 + - Malformed file header lines (lines that start with `diff --git`) 63 82 64 83 2. Errors for invalid input are generally more verbose and specific than those 65 84 from `git apply`. ··· 75 94 5. When reading "traditional" patches (those not produced by `git`), prefixes 76 95 are not stripped from file names; `git apply` attempts to remove prefixes 77 96 that match the current repository directory/prefix. 97 + 98 + 6. Patches can only be applied in "strict" mode, where the line numbers and 99 + context of each fragment must exactly match the source file; `git apply` 100 + implements a search algorithm that tries different lines and amounts of 101 + context, with further options to normalize or ignore whitespace changes. 102 + 103 + 7. When parsing mail-formatted patch headers, leading and trailing whitespace 104 + is always removed from `Subject` lines. There is no exact equivalent to `git 105 + mailinfo -k`.
+147
gitdiff/apply.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "sort" 8 + ) 9 + 10 + // Conflict indicates an apply failed due to a conflict between the patch and 11 + // the source content. 12 + // 13 + // Users can test if an error was caused by a conflict by using errors.Is with 14 + // an empty Conflict: 15 + // 16 + // if errors.Is(err, &Conflict{}) { 17 + // // handle conflict 18 + // } 19 + type Conflict struct { 20 + msg string 21 + } 22 + 23 + func (c *Conflict) Error() string { 24 + return "conflict: " + c.msg 25 + } 26 + 27 + // Is implements error matching for Conflict. Passing an empty instance of 28 + // Conflict always returns true. 29 + func (c *Conflict) Is(other error) bool { 30 + if other, ok := other.(*Conflict); ok { 31 + return other.msg == "" || other.msg == c.msg 32 + } 33 + return false 34 + } 35 + 36 + // ApplyError wraps an error that occurs during patch application with 37 + // additional location information, if it is available. 38 + type ApplyError struct { 39 + // Line is the one-indexed line number in the source data 40 + Line int64 41 + // Fragment is the one-indexed fragment number in the file 42 + Fragment int 43 + // FragmentLine is the one-indexed line number in the fragment 44 + FragmentLine int 45 + 46 + err error 47 + } 48 + 49 + // Unwrap returns the wrapped error. 50 + func (e *ApplyError) Unwrap() error { 51 + return e.err 52 + } 53 + 54 + func (e *ApplyError) Error() string { 55 + return fmt.Sprintf("%v", e.err) 56 + } 57 + 58 + type lineNum int 59 + type fragNum int 60 + type fragLineNum int 61 + 62 + // applyError creates a new *ApplyError wrapping err or augments the information 63 + // in err with args if it is already an *ApplyError. Returns nil if err is nil. 64 + func applyError(err error, args ...interface{}) error { 65 + if err == nil { 66 + return nil 67 + } 68 + 69 + e, ok := err.(*ApplyError) 70 + if !ok { 71 + if err == io.EOF { 72 + err = io.ErrUnexpectedEOF 73 + } 74 + e = &ApplyError{err: err} 75 + } 76 + for _, arg := range args { 77 + switch v := arg.(type) { 78 + case lineNum: 79 + e.Line = int64(v) + 1 80 + case fragNum: 81 + e.Fragment = int(v) + 1 82 + case fragLineNum: 83 + e.FragmentLine = int(v) + 1 84 + } 85 + } 86 + return e 87 + } 88 + 89 + var ( 90 + errApplyInProgress = errors.New("gitdiff: incompatible apply in progress") 91 + errApplierClosed = errors.New("gitdiff: applier is closed") 92 + ) 93 + 94 + // Apply applies the changes in f to src, writing the result to dst. It can 95 + // apply both text and binary changes. 96 + // 97 + // If an error occurs while applying, Apply returns an *ApplyError that 98 + // annotates the error with additional information. If the error is because of 99 + // a conflict with the source, the wrapped error will be a *Conflict. 100 + func Apply(dst io.Writer, src io.ReaderAt, f *File) error { 101 + if f.IsBinary { 102 + if len(f.TextFragments) > 0 { 103 + return applyError(errors.New("binary file contains text fragments")) 104 + } 105 + if f.BinaryFragment == nil { 106 + return applyError(errors.New("binary file does not contain a binary fragment")) 107 + } 108 + } else { 109 + if f.BinaryFragment != nil { 110 + return applyError(errors.New("text file contains a binary fragment")) 111 + } 112 + } 113 + 114 + switch { 115 + case f.BinaryFragment != nil: 116 + applier := NewBinaryApplier(dst, src) 117 + if err := applier.ApplyFragment(f.BinaryFragment); err != nil { 118 + return err 119 + } 120 + return applier.Close() 121 + 122 + case len(f.TextFragments) > 0: 123 + frags := make([]*TextFragment, len(f.TextFragments)) 124 + copy(frags, f.TextFragments) 125 + 126 + sort.Slice(frags, func(i, j int) bool { 127 + return frags[i].OldPosition < frags[j].OldPosition 128 + }) 129 + 130 + // TODO(bkeyes): consider merging overlapping fragments 131 + // right now, the application fails if fragments overlap, but it should be 132 + // possible to precompute the result of applying them in order 133 + 134 + applier := NewTextApplier(dst, src) 135 + for i, frag := range frags { 136 + if err := applier.ApplyFragment(frag); err != nil { 137 + return applyError(err, fragNum(i)) 138 + } 139 + } 140 + return applier.Close() 141 + 142 + default: 143 + // nothing to apply, just copy all the data 144 + _, err := copyFrom(dst, src, 0) 145 + return err 146 + } 147 + }
+206
gitdiff/apply_binary.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + ) 8 + 9 + // BinaryApplier applies binary changes described in a fragment to source data. 10 + // The applier must be closed after use. 11 + type BinaryApplier struct { 12 + dst io.Writer 13 + src io.ReaderAt 14 + 15 + closed bool 16 + dirty bool 17 + } 18 + 19 + // NewBinaryApplier creates an BinaryApplier that reads data from src and 20 + // writes modified data to dst. 21 + func NewBinaryApplier(dst io.Writer, src io.ReaderAt) *BinaryApplier { 22 + a := BinaryApplier{ 23 + dst: dst, 24 + src: src, 25 + } 26 + return &a 27 + } 28 + 29 + // ApplyFragment applies the changes in the fragment f and writes the result to 30 + // dst. ApplyFragment can be called at most once. 31 + // 32 + // If an error occurs while applying, ApplyFragment returns an *ApplyError that 33 + // annotates the error with additional information. If the error is because of 34 + // a conflict between a fragment and the source, the wrapped error will be a 35 + // *Conflict. 36 + func (a *BinaryApplier) ApplyFragment(f *BinaryFragment) error { 37 + if f == nil { 38 + return applyError(errors.New("nil fragment")) 39 + } 40 + if a.closed { 41 + return applyError(errApplierClosed) 42 + } 43 + if a.dirty { 44 + return applyError(errApplyInProgress) 45 + } 46 + 47 + // mark an apply as in progress, even if it fails before making changes 48 + a.dirty = true 49 + 50 + switch f.Method { 51 + case BinaryPatchLiteral: 52 + if _, err := a.dst.Write(f.Data); err != nil { 53 + return applyError(err) 54 + } 55 + case BinaryPatchDelta: 56 + if err := applyBinaryDeltaFragment(a.dst, a.src, f.Data); err != nil { 57 + return applyError(err) 58 + } 59 + default: 60 + return applyError(fmt.Errorf("unsupported binary patch method: %v", f.Method)) 61 + } 62 + return nil 63 + } 64 + 65 + // Close writes any data following the last applied fragment and prevents 66 + // future calls to ApplyFragment. 67 + func (a *BinaryApplier) Close() (err error) { 68 + if a.closed { 69 + return nil 70 + } 71 + 72 + a.closed = true 73 + if !a.dirty { 74 + _, err = copyFrom(a.dst, a.src, 0) 75 + } else { 76 + // do nothing, applying a binary fragment copies all data 77 + } 78 + return err 79 + } 80 + 81 + func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte) error { 82 + srcSize, delta := readBinaryDeltaSize(frag) 83 + if err := checkBinarySrcSize(src, srcSize); err != nil { 84 + return err 85 + } 86 + 87 + dstSize, delta := readBinaryDeltaSize(delta) 88 + 89 + for len(delta) > 0 { 90 + op := delta[0] 91 + if op == 0 { 92 + return errors.New("invalid delta opcode 0") 93 + } 94 + 95 + var n int64 96 + var err error 97 + switch op & 0x80 { 98 + case 0x80: 99 + n, delta, err = applyBinaryDeltaCopy(dst, op, delta[1:], src) 100 + case 0x00: 101 + n, delta, err = applyBinaryDeltaAdd(dst, op, delta[1:]) 102 + } 103 + if err != nil { 104 + return err 105 + } 106 + dstSize -= n 107 + } 108 + 109 + if dstSize != 0 { 110 + return errors.New("corrupt binary delta: insufficient or extra data") 111 + } 112 + return nil 113 + } 114 + 115 + // readBinaryDeltaSize reads a variable length size from a delta-encoded binary 116 + // fragment, returing the size and the unused data. Data is encoded as: 117 + // 118 + // [[1xxxxxxx]...] [0xxxxxxx] 119 + // 120 + // in little-endian order, with 7 bits of the value per byte. 121 + func readBinaryDeltaSize(d []byte) (size int64, rest []byte) { 122 + shift := uint(0) 123 + for i, b := range d { 124 + size |= int64(b&0x7F) << shift 125 + shift += 7 126 + if b <= 0x7F { 127 + return size, d[i+1:] 128 + } 129 + } 130 + return size, nil 131 + } 132 + 133 + // applyBinaryDeltaAdd applies an add opcode in a delta-encoded binary 134 + // fragment, returning the amount of data written and the usused part of the 135 + // fragment. An add operation takes the form: 136 + // 137 + // [0xxxxxx][[data1]...] 138 + // 139 + // where the lower seven bits of the opcode is the number of data bytes 140 + // following the opcode. See also pack-format.txt in the Git source. 141 + func applyBinaryDeltaAdd(w io.Writer, op byte, delta []byte) (n int64, rest []byte, err error) { 142 + size := int(op) 143 + if len(delta) < size { 144 + return 0, delta, errors.New("corrupt binary delta: incomplete add") 145 + } 146 + _, err = w.Write(delta[:size]) 147 + return int64(size), delta[size:], err 148 + } 149 + 150 + // applyBinaryDeltaCopy applies a copy opcode in a delta-encoded binary 151 + // fragment, returing the amount of data written and the unused part of the 152 + // fragment. A copy operation takes the form: 153 + // 154 + // [1xxxxxxx][offset1][offset2][offset3][offset4][size1][size2][size3] 155 + // 156 + // where the lower seven bits of the opcode determine which non-zero offset and 157 + // size bytes are present in little-endian order: if bit 0 is set, offset1 is 158 + // present, etc. If no offset or size bytes are present, offset is 0 and size 159 + // is 0x10000. See also pack-format.txt in the Git source. 160 + func applyBinaryDeltaCopy(w io.Writer, op byte, delta []byte, src io.ReaderAt) (n int64, rest []byte, err error) { 161 + const defaultSize = 0x10000 162 + 163 + unpack := func(start, bits uint) (v int64) { 164 + for i := uint(0); i < bits; i++ { 165 + mask := byte(1 << (i + start)) 166 + if op&mask > 0 { 167 + if len(delta) == 0 { 168 + err = errors.New("corrupt binary delta: incomplete copy") 169 + return 170 + } 171 + v |= int64(delta[0]) << (8 * i) 172 + delta = delta[1:] 173 + } 174 + } 175 + return 176 + } 177 + 178 + offset := unpack(0, 4) 179 + size := unpack(4, 3) 180 + if err != nil { 181 + return 0, delta, err 182 + } 183 + if size == 0 { 184 + size = defaultSize 185 + } 186 + 187 + // TODO(bkeyes): consider pooling these buffers 188 + b := make([]byte, size) 189 + if _, err := src.ReadAt(b, offset); err != nil { 190 + return 0, delta, err 191 + } 192 + 193 + _, err = w.Write(b) 194 + return size, delta, err 195 + } 196 + 197 + func checkBinarySrcSize(r io.ReaderAt, size int64) error { 198 + ok, err := isLen(r, size) 199 + if err != nil { 200 + return err 201 + } 202 + if !ok { 203 + return &Conflict{"fragment src size does not match actual src size"} 204 + } 205 + return nil 206 + }
+235
gitdiff/apply_test.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "bytes" 5 + "errors" 6 + "io" 7 + "io/ioutil" 8 + "path/filepath" 9 + "testing" 10 + ) 11 + 12 + func TestApplyTextFragment(t *testing.T) { 13 + tests := map[string]applyTest{ 14 + "createFile": {Files: getApplyFiles("text_fragment_new")}, 15 + "deleteFile": {Files: getApplyFiles("text_fragment_delete_all")}, 16 + 17 + "addStart": {Files: getApplyFiles("text_fragment_add_start")}, 18 + "addMiddle": {Files: getApplyFiles("text_fragment_add_middle")}, 19 + "addEnd": {Files: getApplyFiles("text_fragment_add_end")}, 20 + "addEndNoEOL": {Files: getApplyFiles("text_fragment_add_end_noeol")}, 21 + 22 + "changeStart": {Files: getApplyFiles("text_fragment_change_start")}, 23 + "changeMiddle": {Files: getApplyFiles("text_fragment_change_middle")}, 24 + "changeEnd": {Files: getApplyFiles("text_fragment_change_end")}, 25 + "changeEndEOL": {Files: getApplyFiles("text_fragment_change_end_eol")}, 26 + "changeExact": {Files: getApplyFiles("text_fragment_change_exact")}, 27 + "changeSingleNoEOL": {Files: getApplyFiles("text_fragment_change_single_noeol")}, 28 + 29 + "errorShortSrcBefore": { 30 + Files: applyFiles{ 31 + Src: "text_fragment_error.src", 32 + Patch: "text_fragment_error_short_src_before.patch", 33 + }, 34 + Err: io.ErrUnexpectedEOF, 35 + }, 36 + "errorShortSrc": { 37 + Files: applyFiles{ 38 + Src: "text_fragment_error.src", 39 + Patch: "text_fragment_error_short_src.patch", 40 + }, 41 + Err: io.ErrUnexpectedEOF, 42 + }, 43 + "errorContextConflict": { 44 + Files: applyFiles{ 45 + Src: "text_fragment_error.src", 46 + Patch: "text_fragment_error_context_conflict.patch", 47 + }, 48 + Err: &Conflict{}, 49 + }, 50 + "errorDeleteConflict": { 51 + Files: applyFiles{ 52 + Src: "text_fragment_error.src", 53 + Patch: "text_fragment_error_delete_conflict.patch", 54 + }, 55 + Err: &Conflict{}, 56 + }, 57 + "errorNewFile": { 58 + Files: applyFiles{ 59 + Src: "text_fragment_error.src", 60 + Patch: "text_fragment_error_new_file.patch", 61 + }, 62 + Err: &Conflict{}, 63 + }, 64 + } 65 + 66 + for name, test := range tests { 67 + t.Run(name, func(t *testing.T) { 68 + test.run(t, func(dst io.Writer, src io.ReaderAt, file *File) error { 69 + if len(file.TextFragments) != 1 { 70 + t.Fatalf("patch should contain exactly one fragment, but it has %d", len(file.TextFragments)) 71 + } 72 + applier := NewTextApplier(dst, src) 73 + return applier.ApplyFragment(file.TextFragments[0]) 74 + }) 75 + }) 76 + } 77 + } 78 + 79 + func TestApplyBinaryFragment(t *testing.T) { 80 + tests := map[string]applyTest{ 81 + "literalCreate": {Files: getApplyFiles("bin_fragment_literal_create")}, 82 + "literalModify": {Files: getApplyFiles("bin_fragment_literal_modify")}, 83 + "deltaModify": {Files: getApplyFiles("bin_fragment_delta_modify")}, 84 + "deltaModifyLarge": {Files: getApplyFiles("bin_fragment_delta_modify_large")}, 85 + 86 + "errorIncompleteAdd": { 87 + Files: applyFiles{ 88 + Src: "bin_fragment_delta_error.src", 89 + Patch: "bin_fragment_delta_error_incomplete_add.patch", 90 + }, 91 + Err: "incomplete add", 92 + }, 93 + "errorIncompleteCopy": { 94 + Files: applyFiles{ 95 + Src: "bin_fragment_delta_error.src", 96 + Patch: "bin_fragment_delta_error_incomplete_copy.patch", 97 + }, 98 + Err: "incomplete copy", 99 + }, 100 + "errorSrcSize": { 101 + Files: applyFiles{ 102 + Src: "bin_fragment_delta_error.src", 103 + Patch: "bin_fragment_delta_error_src_size.patch", 104 + }, 105 + Err: &Conflict{}, 106 + }, 107 + "errorDstSize": { 108 + Files: applyFiles{ 109 + Src: "bin_fragment_delta_error.src", 110 + Patch: "bin_fragment_delta_error_dst_size.patch", 111 + }, 112 + Err: "insufficient or extra data", 113 + }, 114 + } 115 + 116 + for name, test := range tests { 117 + t.Run(name, func(t *testing.T) { 118 + test.run(t, func(dst io.Writer, src io.ReaderAt, file *File) error { 119 + applier := NewBinaryApplier(dst, src) 120 + return applier.ApplyFragment(file.BinaryFragment) 121 + }) 122 + }) 123 + } 124 + } 125 + 126 + func TestApplyFile(t *testing.T) { 127 + tests := map[string]applyTest{ 128 + "textModify": { 129 + Files: applyFiles{ 130 + Src: "file_text.src", 131 + Patch: "file_text_modify.patch", 132 + Out: "file_text_modify.out", 133 + }, 134 + }, 135 + "textDelete": { 136 + Files: applyFiles{ 137 + Src: "file_text.src", 138 + Patch: "file_text_delete.patch", 139 + Out: "file_text_delete.out", 140 + }, 141 + }, 142 + "textErrorPartialDelete": { 143 + Files: applyFiles{ 144 + Src: "file_text.src", 145 + Patch: "file_text_error_partial_delete.patch", 146 + }, 147 + Err: &Conflict{}, 148 + }, 149 + "binaryModify": { 150 + Files: getApplyFiles("file_bin_modify"), 151 + }, 152 + "modeChange": { 153 + Files: getApplyFiles("file_mode_change"), 154 + }, 155 + } 156 + 157 + for name, test := range tests { 158 + t.Run(name, func(t *testing.T) { 159 + test.run(t, func(dst io.Writer, src io.ReaderAt, file *File) error { 160 + return Apply(dst, src, file) 161 + }) 162 + }) 163 + } 164 + } 165 + 166 + type applyTest struct { 167 + Files applyFiles 168 + Err interface{} 169 + } 170 + 171 + func (at applyTest) run(t *testing.T, apply func(io.Writer, io.ReaderAt, *File) error) { 172 + src, patch, out := at.Files.Load(t) 173 + 174 + files, _, err := Parse(bytes.NewReader(patch)) 175 + if err != nil { 176 + t.Fatalf("failed to parse patch file: %v", err) 177 + } 178 + if len(files) != 1 { 179 + t.Fatalf("patch should contain exactly one file, but it has %d", len(files)) 180 + } 181 + 182 + var dst bytes.Buffer 183 + err = apply(&dst, bytes.NewReader(src), files[0]) 184 + if at.Err != nil { 185 + assertError(t, at.Err, err, "applying fragment") 186 + return 187 + } 188 + if err != nil { 189 + var aerr *ApplyError 190 + if errors.As(err, &aerr) { 191 + t.Fatalf("unexpected error applying: at %d: fragment %d at %d: %v", aerr.Line, aerr.Fragment, aerr.FragmentLine, err) 192 + } else { 193 + t.Fatalf("unexpected error applying: %v", err) 194 + } 195 + } 196 + 197 + if !bytes.Equal(out, dst.Bytes()) { 198 + t.Errorf("incorrect result after apply\nexpected:\n%q\nactual:\n%q", out, dst.Bytes()) 199 + } 200 + } 201 + 202 + type applyFiles struct { 203 + Src string 204 + Patch string 205 + Out string 206 + } 207 + 208 + func getApplyFiles(name string) applyFiles { 209 + return applyFiles{ 210 + Src: name + ".src", 211 + Patch: name + ".patch", 212 + Out: name + ".out", 213 + } 214 + } 215 + 216 + func (f applyFiles) Load(t *testing.T) (src []byte, patch []byte, out []byte) { 217 + load := func(name, kind string) []byte { 218 + d, err := ioutil.ReadFile(filepath.Join("testdata", "apply", name)) 219 + if err != nil { 220 + t.Fatalf("failed to read %s file: %v", kind, err) 221 + } 222 + return d 223 + } 224 + 225 + if f.Src != "" { 226 + src = load(f.Src, "source") 227 + } 228 + if f.Patch != "" { 229 + patch = load(f.Patch, "patch") 230 + } 231 + if f.Out != "" { 232 + out = load(f.Out, "output") 233 + } 234 + return 235 + }
+152
gitdiff/apply_text.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "io" 5 + ) 6 + 7 + // TextApplier applies changes described in text fragments to source data. If 8 + // changes are described in multiple fragments, those fragments must be applied 9 + // in order. The applier must be closed after use. 10 + // 11 + // By default, TextApplier operates in "strict" mode, where fragment content 12 + // and positions must exactly match those of the source. 13 + type TextApplier struct { 14 + dst io.Writer 15 + src io.ReaderAt 16 + lineSrc LineReaderAt 17 + nextLine int64 18 + 19 + closed bool 20 + dirty bool 21 + } 22 + 23 + // NewTextApplier creates a TextApplier that reads data from src and writes 24 + // modified data to dst. If src implements LineReaderAt, it is used directly. 25 + func NewTextApplier(dst io.Writer, src io.ReaderAt) *TextApplier { 26 + a := TextApplier{ 27 + dst: dst, 28 + src: src, 29 + } 30 + 31 + if lineSrc, ok := src.(LineReaderAt); ok { 32 + a.lineSrc = lineSrc 33 + } else { 34 + a.lineSrc = &lineReaderAt{r: src} 35 + } 36 + 37 + return &a 38 + } 39 + 40 + // ApplyFragment applies the changes in the fragment f, writing unwritten data 41 + // before the start of the fragment and any changes from the fragment. If 42 + // multiple text fragments apply to the same content, ApplyFragment must be 43 + // called in order of increasing start position. As a result, each fragment can 44 + // be applied at most once. 45 + // 46 + // If an error occurs while applying, ApplyFragment returns an *ApplyError that 47 + // annotates the error with additional information. If the error is because of 48 + // a conflict between the fragment and the source, the wrapped error will be a 49 + // *Conflict. 50 + func (a *TextApplier) ApplyFragment(f *TextFragment) error { 51 + if a.closed { 52 + return applyError(errApplierClosed) 53 + } 54 + 55 + // mark an apply as in progress, even if it fails before making changes 56 + a.dirty = true 57 + 58 + // application code assumes fragment fields are consistent 59 + if err := f.Validate(); err != nil { 60 + return applyError(err) 61 + } 62 + 63 + // lines are 0-indexed, positions are 1-indexed (but new files have position = 0) 64 + fragStart := f.OldPosition - 1 65 + if fragStart < 0 { 66 + fragStart = 0 67 + } 68 + fragEnd := fragStart + f.OldLines 69 + 70 + start := a.nextLine 71 + if fragStart < start { 72 + return applyError(&Conflict{"fragment overlaps with an applied fragment"}) 73 + } 74 + 75 + if f.OldPosition == 0 { 76 + ok, err := isLen(a.src, 0) 77 + if err != nil { 78 + return applyError(err) 79 + } 80 + if !ok { 81 + return applyError(&Conflict{"cannot create new file from non-empty src"}) 82 + } 83 + } 84 + 85 + preimage := make([][]byte, fragEnd-start) 86 + n, err := a.lineSrc.ReadLinesAt(preimage, start) 87 + if err != nil { 88 + return applyError(err, lineNum(start+int64(n))) 89 + } 90 + 91 + // copy leading data before the fragment starts 92 + for i, line := range preimage[:fragStart-start] { 93 + if _, err := a.dst.Write(line); err != nil { 94 + a.nextLine = start + int64(i) 95 + return applyError(err, lineNum(a.nextLine)) 96 + } 97 + } 98 + preimage = preimage[fragStart-start:] 99 + 100 + // apply the changes in the fragment 101 + used := int64(0) 102 + for i, line := range f.Lines { 103 + if err := applyTextLine(a.dst, line, preimage, used); err != nil { 104 + a.nextLine = fragStart + used 105 + return applyError(err, lineNum(a.nextLine), fragLineNum(i)) 106 + } 107 + if line.Old() { 108 + used++ 109 + } 110 + } 111 + a.nextLine = fragStart + used 112 + 113 + // new position of +0,0 mean a full delete, so check for leftovers 114 + if f.NewPosition == 0 && f.NewLines == 0 { 115 + var b [1][]byte 116 + n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine) 117 + if err != nil && err != io.EOF { 118 + return applyError(err, lineNum(a.nextLine)) 119 + } 120 + if n > 0 { 121 + return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine)) 122 + } 123 + } 124 + 125 + return nil 126 + } 127 + 128 + func applyTextLine(dst io.Writer, line Line, preimage [][]byte, i int64) (err error) { 129 + if line.Old() && string(preimage[i]) != line.Line { 130 + return &Conflict{"fragment line does not match src line"} 131 + } 132 + if line.New() { 133 + _, err = io.WriteString(dst, line.Line) 134 + } 135 + return err 136 + } 137 + 138 + // Close writes any data following the last applied fragment and prevents 139 + // future calls to ApplyFragment. 140 + func (a *TextApplier) Close() (err error) { 141 + if a.closed { 142 + return nil 143 + } 144 + 145 + a.closed = true 146 + if !a.dirty { 147 + _, err = copyFrom(a.dst, a.src, 0) 148 + } else { 149 + _, err = copyLinesFrom(a.dst, a.lineSrc, a.nextLine) 150 + } 151 + return err 152 + }
+30
gitdiff/assert_test.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "errors" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func assertError(t *testing.T, expected interface{}, actual error, action string) { 10 + if actual == nil { 11 + t.Fatalf("expected error %s, but got nil", action) 12 + } 13 + 14 + switch exp := expected.(type) { 15 + case bool: 16 + if !exp { 17 + t.Fatalf("unexpected error %s: %v", action, actual) 18 + } 19 + case string: 20 + if !strings.Contains(actual.Error(), exp) { 21 + t.Fatalf("incorrect error %s: %q does not contain %q", action, actual.Error(), exp) 22 + } 23 + case error: 24 + if !errors.Is(actual, exp) { 25 + t.Fatalf("incorrect error %s: expected %T (%v), actual: %T (%v)", action, exp, exp, actual, actual) 26 + } 27 + default: 28 + t.Fatalf("unsupported expected error type: %T", exp) 29 + } 30 + }
+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
··· 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
··· 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
··· 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,
+103 -27
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) { ··· 172 172 // If the names in the header do not match because the patch is a rename, 173 173 // return an empty default name. 174 174 func parseGitHeaderName(header string) (string, error) { 175 - firstName, n, err := parseName(header, -1, 1) 176 - if err != nil { 177 - return "", err 175 + header = strings.TrimSuffix(header, "\n") 176 + if len(header) == 0 { 177 + return "", nil 178 178 } 179 179 180 - if n < len(header) && (header[n] == ' ' || header[n] == '\t') { 181 - n++ 182 - } 180 + var err error 181 + var first, second string 182 + 183 + // there are 4 cases to account for: 184 + // 185 + // 1) unquoted unquoted 186 + // 2) unquoted "quoted" 187 + // 3) "quoted" unquoted 188 + // 4) "quoted" "quoted" 189 + // 190 + quote := strings.IndexByte(header, '"') 191 + switch { 192 + case quote < 0: 193 + // case 1 194 + first = header 195 + 196 + case quote > 0: 197 + // case 2 198 + first = header[:quote-1] 199 + if !isSpace(header[quote-1]) { 200 + return "", fmt.Errorf("missing separator") 201 + } 202 + 203 + second, _, err = parseQuotedName(header[quote:]) 204 + if err != nil { 205 + return "", err 206 + } 207 + 208 + case quote == 0: 209 + // case 3 or case 4 210 + var n int 211 + first, n, err = parseQuotedName(header) 212 + if err != nil { 213 + return "", err 214 + } 215 + 216 + // git accepts multiple spaces after a quoted name, but not after an 217 + // unquoted name, since the name might end with one or more spaces 218 + for n < len(header) && isSpace(header[n]) { 219 + n++ 220 + } 221 + if n == len(header) { 222 + return "", nil 223 + } 183 224 184 - secondName, _, err := parseName(header[n:], -1, 1) 185 - if err != nil { 186 - return "", err 225 + if header[n] == '"' { 226 + second, _, err = parseQuotedName(header[n:]) 227 + if err != nil { 228 + return "", err 229 + } 230 + } else { 231 + second = header[n:] 232 + } 187 233 } 188 234 189 - if firstName != secondName { 235 + first = trimTreePrefix(first, 1) 236 + if second != "" { 237 + if first == trimTreePrefix(second, 1) { 238 + return first, nil 239 + } 190 240 return "", nil 191 241 } 192 - return firstName, nil 242 + 243 + // at this point, both names are unquoted (case 1) 244 + // since names may contain spaces, we can't use a known separator 245 + // instead, look for a split that produces two equal names 246 + 247 + for i := 0; i < len(first)-1; i++ { 248 + if !isSpace(first[i]) { 249 + continue 250 + } 251 + second = trimTreePrefix(first[i+1:], 1) 252 + if name := first[:i]; name == second { 253 + return name, nil 254 + } 255 + } 256 + return "", nil 193 257 } 194 258 195 259 // parseGitHeaderData parses a single line of metadata from a Git file header. ··· 260 324 } 261 325 262 326 func parseGitHeaderOldMode(f *File, line, defaultName string) (err error) { 263 - f.OldMode, err = parseMode(line) 327 + f.OldMode, err = parseMode(strings.TrimSpace(line)) 264 328 return 265 329 } 266 330 267 331 func parseGitHeaderNewMode(f *File, line, defaultName string) (err error) { 268 - f.NewMode, err = parseMode(line) 332 + f.NewMode, err = parseMode(strings.TrimSpace(line)) 269 333 return 270 334 } 271 335 ··· 283 347 284 348 func parseGitHeaderCopyFrom(f *File, line, defaultName string) (err error) { 285 349 f.IsCopy = true 286 - f.OldName, _, err = parseName(line, -1, 0) 350 + f.OldName, _, err = parseName(line, 0, 0) 287 351 return 288 352 } 289 353 290 354 func parseGitHeaderCopyTo(f *File, line, defaultName string) (err error) { 291 355 f.IsCopy = true 292 - f.NewName, _, err = parseName(line, -1, 0) 356 + f.NewName, _, err = parseName(line, 0, 0) 293 357 return 294 358 } 295 359 296 360 func parseGitHeaderRenameFrom(f *File, line, defaultName string) (err error) { 297 361 f.IsRename = true 298 - f.OldName, _, err = parseName(line, -1, 0) 362 + f.OldName, _, err = parseName(line, 0, 0) 299 363 return 300 364 } 301 365 302 366 func parseGitHeaderRenameTo(f *File, line, defaultName string) (err error) { 303 367 f.IsRename = true 304 - f.NewName, _, err = parseName(line, -1, 0) 368 + f.NewName, _, err = parseName(line, 0, 0) 305 369 return 306 370 } 307 371 ··· 349 413 350 414 // parseName extracts a file name from the start of a string and returns the 351 415 // name and the index of the first character after the name. If the name is 352 - // unquoted and term is non-negative, parsing stops at the first occurrence of 353 - // term. Otherwise parsing of unquoted names stops at the first space or tab. 416 + // unquoted and term is non-zero, parsing stops at the first occurrence of 417 + // term. 354 418 // 355 419 // If the name is exactly "/dev/null", no further processing occurs. Otherwise, 356 420 // if dropPrefix is greater than zero, that number of prefix components 357 421 // separated by forward slashes are dropped from the name and any duplicate 358 422 // slashes are collapsed. 359 - func parseName(s string, term rune, dropPrefix int) (name string, n int, err error) { 423 + func parseName(s string, term byte, dropPrefix int) (name string, n int, err error) { 360 424 if len(s) > 0 && s[0] == '"' { 361 425 name, n, err = parseQuotedName(s) 362 426 } else { ··· 387 451 return name, n, err 388 452 } 389 453 390 - func parseUnquotedName(s string, term rune) (name string, n int, err error) { 454 + func parseUnquotedName(s string, term byte) (name string, n int, err error) { 391 455 for n = 0; n < len(s); n++ { 392 456 if s[n] == '\n' { 393 457 break 394 458 } 395 - if term >= 0 && rune(s[n]) == term { 396 - break 397 - } 398 - if term < 0 && (s[n] == ' ' || s[n] == '\t') { 459 + if term > 0 && s[n] == term { 399 460 break 400 461 } 401 462 } ··· 440 501 return b.String() 441 502 } 442 503 504 + // trimTreePrefix removes up to n leading directory components from name. 505 + func trimTreePrefix(name string, n int) string { 506 + i := 0 507 + for ; i < len(name) && n > 0; i++ { 508 + if name[i] == '/' { 509 + n-- 510 + } 511 + } 512 + return name[i:] 513 + } 514 + 443 515 // hasEpochTimestamp returns true if the string ends with a POSIX-formatted 444 516 // timestamp for the UNIX epoch after a tab character. According to git, this 445 517 // is used by GNU diff to mark creations and deletions. ··· 455 527 456 528 // a valid timestamp can have optional ':' in zone specifier 457 529 // remove that if it exists so we have a single format 458 - if ts[len(ts)-3] == ':' { 530 + if len(ts) >= 3 && ts[len(ts)-3] == ':' { 459 531 ts = ts[:len(ts)-3] + ts[len(ts)-2:] 460 532 } 461 533 ··· 468 540 } 469 541 return true 470 542 } 543 + 544 + func isSpace(c byte) bool { 545 + return c == ' ' || c == '\t' || c == '\n' 546 + }
+64 -8
gitdiff/file_header_test.go
··· 310 310 func TestParseName(t *testing.T) { 311 311 tests := map[string]struct { 312 312 Input string 313 - Term rune 313 + Term byte 314 314 Drop int 315 315 Output string 316 316 N int ··· 334 334 "dropPrefix": { 335 335 Input: "a/dir/file.txt", Drop: 1, Output: "dir/file.txt", N: 14, 336 336 }, 337 - "multipleNames": { 338 - Input: "dir/a.txt dir/b.txt", Term: -1, Output: "dir/a.txt", N: 9, 337 + "unquotedWithSpaces": { 338 + Input: "dir/with spaces.txt", Output: "dir/with spaces.txt", N: 19, 339 + }, 340 + "unquotedWithTrailingSpaces": { 341 + Input: "dir/with spaces.space ", Output: "dir/with spaces.space ", N: 23, 339 342 }, 340 343 "devNull": { 341 344 Input: "/dev/null", Term: '\t', Drop: 1, Output: "/dev/null", N: 9, 342 345 }, 343 - "newlineAlwaysSeparates": { 344 - Input: "dir/file.txt\n", Term: 0, Output: "dir/file.txt", N: 12, 346 + "newlineSeparates": { 347 + Input: "dir/file.txt\n", Output: "dir/file.txt", N: 12, 345 348 }, 346 349 "emptyString": { 347 350 Input: "", Err: true, ··· 483 486 OldMode: os.FileMode(0100644), 484 487 }, 485 488 }, 489 + "oldModeWithTrailingSpace": { 490 + Line: "old mode 100644\r\n", 491 + OutputFile: &File{ 492 + OldMode: os.FileMode(0100644), 493 + }, 494 + }, 486 495 "invalidOldMode": { 487 496 Line: "old mode rw\n", 488 497 Err: true, 489 498 }, 490 499 "newMode": { 491 500 Line: "new mode 100755\n", 501 + OutputFile: &File{ 502 + NewMode: os.FileMode(0100755), 503 + }, 504 + }, 505 + "newModeWithTrailingSpace": { 506 + Line: "new mode 100755\r\n", 492 507 OutputFile: &File{ 493 508 NewMode: os.FileMode(0100755), 494 509 }, ··· 508 523 }, 509 524 "newFileMode": { 510 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", 511 535 DefaultName: "dir/file.txt", 512 536 OutputFile: &File{ 513 537 NewName: "dir/file.txt", ··· 630 654 Input: "a/dir/foo.txt b/dir/bar.txt", 631 655 Output: "", 632 656 }, 633 - "missingSecondName": { 634 - Input: "a/dir/foo.txt", 635 - Err: true, 657 + "matchingNamesWithSpaces": { 658 + Input: "a/dir/file with spaces.txt b/dir/file with spaces.txt", 659 + Output: "dir/file with spaces.txt", 660 + }, 661 + "matchingNamesWithTrailingSpaces": { 662 + Input: "a/dir/spaces b/dir/spaces ", 663 + Output: "dir/spaces ", 664 + }, 665 + "matchingNamesQuoted": { 666 + Input: `"a/dir/\"quotes\".txt" "b/dir/\"quotes\".txt"`, 667 + Output: `dir/"quotes".txt`, 668 + }, 669 + "matchingNamesFirstQuoted": { 670 + Input: `"a/dir/file.txt" b/dir/file.txt`, 671 + Output: "dir/file.txt", 672 + }, 673 + "matchingNamesSecondQuoted": { 674 + Input: `a/dir/file.txt "b/dir/file.txt"`, 675 + Output: "dir/file.txt", 676 + }, 677 + "noSecondName": { 678 + Input: "a/dir/foo.txt", 679 + Output: "", 680 + }, 681 + "noSecondNameQuoted": { 682 + Input: `"a/dir/foo.txt"`, 683 + Output: "", 636 684 }, 637 685 "invalidName": { 638 686 Input: `"a/dir/file.txt b/dir/file.txt`, ··· 695 743 }, 696 744 "notEpoch": { 697 745 Input: "+++ file.txt\t2019-03-21 12:34:56.789 -0700\n", 746 + Output: false, 747 + }, 748 + "notTimestamp": { 749 + Input: "+++ file.txt\trandom text\n", 750 + Output: false, 751 + }, 752 + "notTimestampShort": { 753 + Input: "+++ file.txt\t0\n", 698 754 Output: false, 699 755 }, 700 756 }
+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
··· 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
··· 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 + }
+119 -2
gitdiff/gitdiff.go
··· 1 1 package gitdiff 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "os" 7 + "strings" 6 8 ) 7 9 8 10 // File describes changes to a single file. It can be either a text file or a ··· 37 39 ReverseBinaryFragment *BinaryFragment 38 40 } 39 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 + 40 51 // TextFragment describes changed lines starting at a specific line in a text file. 41 52 type TextFragment struct { 42 53 Comment string ··· 56 67 Lines []Line 57 68 } 58 69 59 - // 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. 60 80 func (f *TextFragment) Header() string { 61 - 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() 84 + } 85 + 86 + // Validate checks that the fragment is self-consistent and appliable. Validate 87 + // returns an error if and only if the fragment is invalid. 88 + func (f *TextFragment) Validate() error { 89 + if f == nil { 90 + return errors.New("nil fragment") 91 + } 92 + 93 + var ( 94 + oldLines, newLines int64 95 + leadingContext, trailingContext int64 96 + contextLines, addedLines, deletedLines int64 97 + ) 98 + 99 + // count the types of lines in the fragment content 100 + for i, line := range f.Lines { 101 + switch line.Op { 102 + case OpContext: 103 + oldLines++ 104 + newLines++ 105 + contextLines++ 106 + if addedLines == 0 && deletedLines == 0 { 107 + leadingContext++ 108 + } else { 109 + trailingContext++ 110 + } 111 + case OpAdd: 112 + newLines++ 113 + addedLines++ 114 + trailingContext = 0 115 + case OpDelete: 116 + oldLines++ 117 + deletedLines++ 118 + trailingContext = 0 119 + default: 120 + return fmt.Errorf("unknown operator %q on line %d", line.Op, i+1) 121 + } 122 + } 123 + 124 + // check the actual counts against the reported counts 125 + if oldLines != f.OldLines { 126 + return lineCountErr("old", oldLines, f.OldLines) 127 + } 128 + if newLines != f.NewLines { 129 + return lineCountErr("new", newLines, f.NewLines) 130 + } 131 + if leadingContext != f.LeadingContext { 132 + return lineCountErr("leading context", leadingContext, f.LeadingContext) 133 + } 134 + if trailingContext != f.TrailingContext { 135 + return lineCountErr("trailing context", trailingContext, f.TrailingContext) 136 + } 137 + if addedLines != f.LinesAdded { 138 + return lineCountErr("added", addedLines, f.LinesAdded) 139 + } 140 + if deletedLines != f.LinesDeleted { 141 + return lineCountErr("deleted", deletedLines, f.LinesDeleted) 142 + } 143 + 144 + // if a file is being created, it can only contain additions 145 + if f.OldPosition == 0 && f.OldLines != 0 { 146 + return errors.New("file creation fragment contains context or deletion lines") 147 + } 148 + 149 + return nil 150 + } 151 + 152 + func lineCountErr(kind string, actual, reported int64) error { 153 + return fmt.Errorf("fragment contains %d %s lines but reports %d", actual, kind, reported) 62 154 } 63 155 64 156 // Line is a line in a text fragment. ··· 69 161 70 162 func (fl Line) String() string { 71 163 return fl.Op.String() + fl.Line 164 + } 165 + 166 + // Old returns true if the line appears in the old content of the fragment. 167 + func (fl Line) Old() bool { 168 + return fl.Op == OpContext || fl.Op == OpDelete 169 + } 170 + 171 + // New returns true if the line appears in the new content of the fragment. 172 + func (fl Line) New() bool { 173 + return fl.Op == OpContext || fl.Op == OpAdd 174 + } 175 + 176 + // NoEOL returns true if the line is missing a trailing newline character. 177 + func (fl Line) NoEOL() bool { 178 + return len(fl.Line) == 0 || fl.Line[len(fl.Line)-1] != '\n' 72 179 } 73 180 74 181 // LineOp describes the type of a text fragment line: context, added, or removed. ··· 111 218 // BinaryPatchLiteral indicates the data is the exact file content 112 219 BinaryPatchLiteral 113 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 + }
+161
gitdiff/gitdiff_test.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestTextFragmentValidate(t *testing.T) { 9 + tests := map[string]struct { 10 + Fragment TextFragment 11 + Err string 12 + }{ 13 + "oldLines": { 14 + Fragment: TextFragment{ 15 + OldPosition: 1, 16 + OldLines: 3, 17 + NewPosition: 1, 18 + NewLines: 2, 19 + LeadingContext: 1, 20 + TrailingContext: 0, 21 + LinesAdded: 1, 22 + LinesDeleted: 1, 23 + Lines: []Line{ 24 + {Op: OpContext, Line: "line 1\n"}, 25 + {Op: OpDelete, Line: "old line 2\n"}, 26 + {Op: OpAdd, Line: "new line 2\n"}, 27 + }, 28 + }, 29 + Err: "2 old lines", 30 + }, 31 + "newLines": { 32 + Fragment: TextFragment{ 33 + OldPosition: 1, 34 + OldLines: 2, 35 + NewPosition: 1, 36 + NewLines: 3, 37 + LeadingContext: 1, 38 + TrailingContext: 0, 39 + LinesAdded: 1, 40 + LinesDeleted: 1, 41 + Lines: []Line{ 42 + {Op: OpContext, Line: "line 1\n"}, 43 + {Op: OpDelete, Line: "old line 2\n"}, 44 + {Op: OpAdd, Line: "new line 2\n"}, 45 + }, 46 + }, 47 + Err: "2 new lines", 48 + }, 49 + "leadingContext": { 50 + Fragment: TextFragment{ 51 + OldPosition: 1, 52 + OldLines: 2, 53 + NewPosition: 1, 54 + NewLines: 2, 55 + LeadingContext: 0, 56 + TrailingContext: 0, 57 + LinesAdded: 1, 58 + LinesDeleted: 1, 59 + Lines: []Line{ 60 + {Op: OpContext, Line: "line 1\n"}, 61 + {Op: OpDelete, Line: "old line 2\n"}, 62 + {Op: OpAdd, Line: "new line 2\n"}, 63 + }, 64 + }, 65 + Err: "1 leading context lines", 66 + }, 67 + "trailingContext": { 68 + Fragment: TextFragment{ 69 + OldPosition: 1, 70 + OldLines: 4, 71 + NewPosition: 1, 72 + NewLines: 3, 73 + LeadingContext: 1, 74 + TrailingContext: 1, 75 + LinesAdded: 1, 76 + LinesDeleted: 2, 77 + Lines: []Line{ 78 + {Op: OpContext, Line: "line 1\n"}, 79 + {Op: OpDelete, Line: "old line 2\n"}, 80 + {Op: OpAdd, Line: "new line 2\n"}, 81 + {Op: OpContext, Line: "line 3\n"}, 82 + {Op: OpDelete, Line: "old line 4\n"}, 83 + }, 84 + }, 85 + Err: "0 trailing context lines", 86 + }, 87 + "linesAdded": { 88 + Fragment: TextFragment{ 89 + OldPosition: 1, 90 + OldLines: 4, 91 + NewPosition: 1, 92 + NewLines: 3, 93 + LeadingContext: 1, 94 + TrailingContext: 0, 95 + LinesAdded: 2, 96 + LinesDeleted: 2, 97 + Lines: []Line{ 98 + {Op: OpContext, Line: "line 1\n"}, 99 + {Op: OpDelete, Line: "old line 2\n"}, 100 + {Op: OpAdd, Line: "new line 2\n"}, 101 + {Op: OpContext, Line: "line 3\n"}, 102 + {Op: OpDelete, Line: "old line 4\n"}, 103 + }, 104 + }, 105 + Err: "1 added lines", 106 + }, 107 + "linesDeleted": { 108 + Fragment: TextFragment{ 109 + OldPosition: 1, 110 + OldLines: 4, 111 + NewPosition: 1, 112 + NewLines: 3, 113 + LeadingContext: 1, 114 + TrailingContext: 0, 115 + LinesAdded: 1, 116 + LinesDeleted: 1, 117 + Lines: []Line{ 118 + {Op: OpContext, Line: "line 1\n"}, 119 + {Op: OpDelete, Line: "old line 2\n"}, 120 + {Op: OpAdd, Line: "new line 2\n"}, 121 + {Op: OpContext, Line: "line 3\n"}, 122 + {Op: OpDelete, Line: "old line 4\n"}, 123 + }, 124 + }, 125 + Err: "2 deleted lines", 126 + }, 127 + "fileCreation": { 128 + Fragment: TextFragment{ 129 + OldPosition: 0, 130 + OldLines: 2, 131 + NewPosition: 1, 132 + NewLines: 1, 133 + LeadingContext: 0, 134 + TrailingContext: 0, 135 + LinesAdded: 1, 136 + LinesDeleted: 2, 137 + Lines: []Line{ 138 + {Op: OpDelete, Line: "old line 1\n"}, 139 + {Op: OpDelete, Line: "old line 2\n"}, 140 + {Op: OpAdd, Line: "new line\n"}, 141 + }, 142 + }, 143 + Err: "creation fragment", 144 + }, 145 + } 146 + 147 + for name, test := range tests { 148 + t.Run(name, func(t *testing.T) { 149 + err := test.Fragment.Validate() 150 + if test.Err == "" && err != nil { 151 + t.Fatalf("unexpected validation error: %v", err) 152 + } 153 + if test.Err != "" && err == nil { 154 + t.Fatal("expected validation error, but got nil") 155 + } 156 + if !strings.Contains(err.Error(), test.Err) { 157 + t.Fatalf("incorrect validation error: %q is not in %q", test.Err, err.Error()) 158 + } 159 + }) 160 + } 161 + }
+203 -41
gitdiff/io.go
··· 1 1 package gitdiff 2 2 3 3 import ( 4 - "bufio" 4 + "errors" 5 5 "io" 6 6 ) 7 7 8 - // StringReader is the interface that wraps the ReadString method. 9 - type StringReader interface { 10 - // ReadString reads until the first occurrence of delim in the input, 11 - // returning a string containing the data up to and including the 12 - // delimiter. If ReadString encounters an error before finding a delimiter, 13 - // it returns the data read before the error and the error itself (often 14 - // io.EOF). ReadString returns err != nil if and only if the returned data 15 - // does not end in delim. 16 - ReadString(delim byte) (string, error) 8 + const ( 9 + byteBufferSize = 32 * 1024 // from io.Copy 10 + lineBufferSize = 32 11 + indexBufferSize = 1024 12 + ) 13 + 14 + // LineReaderAt is the interface that wraps the ReadLinesAt method. 15 + // 16 + // ReadLinesAt reads len(lines) into lines starting at line offset. It returns 17 + // the number of lines read (0 <= n <= len(lines)) and any error encountered. 18 + // Line numbers are zero-indexed. 19 + // 20 + // If n < len(lines), ReadLinesAt returns a non-nil error explaining why more 21 + // lines were not returned. 22 + // 23 + // Lines read by ReadLinesAt include the newline character. The last line does 24 + // not have a final newline character if the input ends without one. 25 + type LineReaderAt interface { 26 + ReadLinesAt(lines [][]byte, offset int64) (n int, err error) 17 27 } 18 28 19 - // LineReader is the interface that wraps the ReadLine method. 20 - type LineReader interface { 21 - // ReadLine reads the next full line in the input, returing the the data 22 - // including the line ending character(s) and the zero-indexed line number. 23 - // If ReadLine encounters an error before reaching the end of the line, it 24 - // returns the data read before the error and the error itself (often 25 - // io.EOF). ReadLine returns err != nil if and only if the returned data is 26 - // not a complete line. 27 - // 28 - // If the implementation defines other methods for reading the same input, 29 - // line numbers may be incorrect if calls to ReadLine are mixed with calls 30 - // to other read methods. 31 - ReadLine() (string, int, error) 29 + type lineReaderAt struct { 30 + r io.ReaderAt 31 + index []int64 32 + eof bool 32 33 } 33 34 34 - // NewLineReader returns a LineReader for a reader starting at a specific line 35 - // using the newline character, \n, as a line separator. If r is a 36 - // StringReader, it is used directly. Otherwise, it is wrapped in a way that 37 - // may read extra data from the underlying input. 38 - func NewLineReader(r io.Reader, lineno int) LineReader { 39 - sr, ok := r.(StringReader) 40 - if !ok { 41 - sr = bufio.NewReader(r) 35 + func (r *lineReaderAt) ReadLinesAt(lines [][]byte, offset int64) (n int, err error) { 36 + if offset < 0 { 37 + return 0, errors.New("ReadLinesAt: negative offset") 42 38 } 43 - return &lineReader{r: sr, n: lineno} 39 + if len(lines) == 0 { 40 + return 0, nil 41 + } 42 + 43 + count := len(lines) 44 + startLine := offset 45 + endLine := startLine + int64(count) 46 + 47 + if endLine > int64(len(r.index)) && !r.eof { 48 + if err := r.indexTo(endLine); err != nil { 49 + return 0, err 50 + } 51 + } 52 + if startLine >= int64(len(r.index)) { 53 + return 0, io.EOF 54 + } 55 + 56 + buf, byteOffset, err := r.readBytes(startLine, int64(count)) 57 + if err != nil { 58 + return 0, err 59 + } 60 + 61 + for n = 0; n < count && startLine+int64(n) < int64(len(r.index)); n++ { 62 + lineno := startLine + int64(n) 63 + start, end := int64(0), r.index[lineno]-byteOffset 64 + if lineno > 0 { 65 + start = r.index[lineno-1] - byteOffset 66 + } 67 + lines[n] = buf[start:end] 68 + } 69 + 70 + if n < count { 71 + return n, io.EOF 72 + } 73 + return n, nil 44 74 } 45 75 46 - type lineReader struct { 47 - r StringReader 48 - n int 76 + // indexTo reads data and computes the line index until there is information 77 + // for line or a read returns io.EOF. It returns an error if and only if there 78 + // is an error reading data. 79 + func (r *lineReaderAt) indexTo(line int64) error { 80 + var buf [indexBufferSize]byte 81 + 82 + offset := r.lastOffset() 83 + for int64(len(r.index)) < line { 84 + n, err := r.r.ReadAt(buf[:], offset) 85 + if err != nil && err != io.EOF { 86 + return err 87 + } 88 + for _, b := range buf[:n] { 89 + offset++ 90 + if b == '\n' { 91 + r.index = append(r.index, offset) 92 + } 93 + } 94 + if err == io.EOF { 95 + if offset > r.lastOffset() { 96 + r.index = append(r.index, offset) 97 + } 98 + r.eof = true 99 + break 100 + } 101 + } 102 + return nil 49 103 } 50 104 51 - func (lr *lineReader) ReadLine() (line string, lineno int, err error) { 52 - lineno = lr.n 53 - line, err = lr.r.ReadString('\n') 54 - if err == nil { 55 - lr.n++ 105 + func (r *lineReaderAt) lastOffset() int64 { 106 + if n := len(r.index); n > 0 { 107 + return r.index[n-1] 108 + } 109 + return 0 110 + } 111 + 112 + // readBytes reads the bytes of the n lines starting at line and returns the 113 + // bytes and the offset of the first byte in the underlying source. 114 + func (r *lineReaderAt) readBytes(line, n int64) (b []byte, offset int64, err error) { 115 + indexLen := int64(len(r.index)) 116 + 117 + var size int64 118 + if line > indexLen { 119 + offset = r.index[indexLen-1] 120 + } else if line > 0 { 121 + offset = r.index[line-1] 122 + } 123 + if n > 0 { 124 + if line+n > indexLen { 125 + size = r.index[indexLen-1] - offset 126 + } else { 127 + size = r.index[line+n-1] - offset 128 + } 129 + } 130 + 131 + b = make([]byte, size) 132 + if _, err := r.r.ReadAt(b, offset); err != nil { 133 + if err == io.EOF { 134 + err = errors.New("ReadLinesAt: corrupt line index or changed source data") 135 + } 136 + return nil, 0, err 56 137 } 57 - return 138 + return b, offset, nil 139 + } 140 + 141 + func isLen(r io.ReaderAt, n int64) (bool, error) { 142 + off := n - 1 143 + if off < 0 { 144 + off = 0 145 + } 146 + 147 + var b [2]byte 148 + nr, err := r.ReadAt(b[:], off) 149 + if err == io.EOF { 150 + return (n == 0 && nr == 0) || (n > 0 && nr == 1), nil 151 + } 152 + return false, err 153 + } 154 + 155 + // copyFrom writes bytes starting from offset off in src to dst stopping at the 156 + // end of src or at the first error. copyFrom returns the number of bytes 157 + // written and any error. 158 + func copyFrom(dst io.Writer, src io.ReaderAt, off int64) (written int64, err error) { 159 + buf := make([]byte, byteBufferSize) 160 + for { 161 + nr, rerr := src.ReadAt(buf, off) 162 + if nr > 0 { 163 + nw, werr := dst.Write(buf[0:nr]) 164 + if nw > 0 { 165 + written += int64(nw) 166 + } 167 + if werr != nil { 168 + err = werr 169 + break 170 + } 171 + if nr != nw { 172 + err = io.ErrShortWrite 173 + break 174 + } 175 + off += int64(nr) 176 + } 177 + if rerr != nil { 178 + if rerr != io.EOF { 179 + err = rerr 180 + } 181 + break 182 + } 183 + } 184 + return written, err 185 + } 186 + 187 + // copyLinesFrom writes lines starting from line off in src to dst stopping at 188 + // the end of src or at the first error. copyLinesFrom returns the number of 189 + // lines written and any error. 190 + func copyLinesFrom(dst io.Writer, src LineReaderAt, off int64) (written int64, err error) { 191 + buf := make([][]byte, lineBufferSize) 192 + ReadLoop: 193 + for { 194 + nr, rerr := src.ReadLinesAt(buf, off) 195 + if nr > 0 { 196 + for _, line := range buf[0:nr] { 197 + nw, werr := dst.Write(line) 198 + if nw > 0 { 199 + written++ 200 + } 201 + if werr != nil { 202 + err = werr 203 + break ReadLoop 204 + } 205 + if len(line) != nw { 206 + err = io.ErrShortWrite 207 + break ReadLoop 208 + } 209 + } 210 + off += int64(nr) 211 + } 212 + if rerr != nil { 213 + if rerr != io.EOF { 214 + err = rerr 215 + } 216 + break 217 + } 218 + } 219 + return written, err 58 220 }
+236 -39
gitdiff/io_test.go
··· 1 1 package gitdiff 2 2 3 3 import ( 4 + "bytes" 5 + "fmt" 4 6 "io" 5 - "strings" 7 + "math/rand" 6 8 "testing" 7 9 ) 8 10 9 - func TestLineReader(t *testing.T) { 10 - const content = "first line\nsecond line\nthird line\npartial fourth line" 11 + func TestLineReaderAt(t *testing.T) { 12 + const lineTemplate = "generated test line %d\n" 11 13 12 - t.Run("readLine", func(t *testing.T) { 13 - r := NewLineReader(strings.NewReader(content), 0) 14 + tests := map[string]struct { 15 + InputLines int 16 + Offset int64 17 + Count int 18 + Err bool 19 + EOF bool 20 + EOFCount int 21 + }{ 22 + "readLines": { 23 + InputLines: 32, 24 + Offset: 0, 25 + Count: 4, 26 + }, 27 + "readLinesOffset": { 28 + InputLines: 32, 29 + Offset: 8, 30 + Count: 4, 31 + }, 32 + "readLinesLargeOffset": { 33 + InputLines: 8192, 34 + Offset: 4096, 35 + Count: 64, 36 + }, 37 + "readSingleLine": { 38 + InputLines: 4, 39 + Offset: 2, 40 + Count: 1, 41 + }, 42 + "readZeroLines": { 43 + InputLines: 4, 44 + Offset: 2, 45 + Count: 0, 46 + }, 47 + "readAllLines": { 48 + InputLines: 64, 49 + Offset: 0, 50 + Count: 64, 51 + }, 52 + "readThroughEOF": { 53 + InputLines: 16, 54 + Offset: 12, 55 + Count: 8, 56 + EOF: true, 57 + EOFCount: 4, 58 + }, 59 + "emptyInput": { 60 + InputLines: 0, 61 + Offset: 0, 62 + Count: 2, 63 + EOF: true, 64 + EOFCount: 0, 65 + }, 66 + "offsetAfterEOF": { 67 + InputLines: 8, 68 + Offset: 10, 69 + Count: 2, 70 + EOF: true, 71 + EOFCount: 0, 72 + }, 73 + "offsetNegative": { 74 + InputLines: 8, 75 + Offset: -1, 76 + Count: 2, 77 + Err: true, 78 + }, 79 + } 14 80 15 - lines := []struct { 16 - Data string 17 - Err error 18 - }{ 19 - {"first line\n", nil}, 20 - {"second line\n", nil}, 21 - {"third line\n", nil}, 22 - {"partial fourth line", io.EOF}, 23 - } 81 + for name, test := range tests { 82 + t.Run(name, func(t *testing.T) { 83 + var input bytes.Buffer 84 + for i := 0; i < test.InputLines; i++ { 85 + fmt.Fprintf(&input, lineTemplate, i) 86 + } 87 + 88 + output := make([][]byte, test.Count) 89 + for i := 0; i < test.Count; i++ { 90 + output[i] = []byte(fmt.Sprintf(lineTemplate, test.Offset+int64(i))) 91 + } 92 + 93 + r := &lineReaderAt{r: bytes.NewReader(input.Bytes())} 94 + lines := make([][]byte, test.Count) 95 + 96 + n, err := r.ReadLinesAt(lines, test.Offset) 97 + if test.Err { 98 + if err == nil { 99 + t.Fatal("expected error reading lines, but got nil") 100 + } 101 + return 102 + } 103 + if err != nil && (!test.EOF || err != io.EOF) { 104 + t.Fatalf("unexpected error reading lines: %v", err) 105 + } 106 + 107 + count := test.Count 108 + if test.EOF { 109 + count = test.EOFCount 110 + } 111 + 112 + if n != count { 113 + t.Fatalf("incorrect number of lines read: expected %d, actual %d", count, n) 114 + } 115 + for i := 0; i < n; i++ { 116 + if !bytes.Equal(output[i], lines[i]) { 117 + t.Errorf("incorrect content in line %d:\nexpected: %q\nactual: %q", i, output[i], lines[i]) 118 + } 119 + } 120 + }) 121 + } 122 + 123 + newlineTests := map[string]struct { 124 + InputSize int 125 + }{ 126 + "readLinesNoFinalNewline": { 127 + InputSize: indexBufferSize + indexBufferSize/2, 128 + }, 129 + "readLinesNoFinalNewlineBufferMultiple": { 130 + InputSize: 4 * indexBufferSize, 131 + }, 132 + } 133 + 134 + for name, test := range newlineTests { 135 + t.Run(name, func(t *testing.T) { 136 + input := bytes.Repeat([]byte("0"), test.InputSize) 24 137 25 - for i, line := range lines { 26 - d, n, err := r.ReadLine() 27 - if err != line.Err { 28 - if line.Err == nil { 29 - t.Fatalf("error reading line: %v", err) 138 + var output [][]byte 139 + for i := 0; i < len(input); i++ { 140 + last := i 141 + i += rand.Intn(80) 142 + if i < len(input)-1 { // last character of input must not be a newline 143 + input[i] = '\n' 144 + output = append(output, input[last:i+1]) 30 145 } else { 31 - t.Fatalf("expected %v while reading line, but got %v", line.Err, err) 146 + output = append(output, input[last:]) 147 + } 148 + } 149 + 150 + r := &lineReaderAt{r: bytes.NewReader(input)} 151 + lines := make([][]byte, len(output)) 152 + 153 + n, err := r.ReadLinesAt(lines, 0) 154 + if err != nil { 155 + t.Fatalf("unexpected error reading reading lines: %v", err) 156 + } 157 + 158 + if n != len(output) { 159 + t.Fatalf("incorrect number of lines read: expected %d, actual %d", len(output), n) 160 + } 161 + 162 + for i, line := range lines { 163 + if !bytes.Equal(output[i], line) { 164 + t.Errorf("incorrect content in line %d:\nexpected: %q\nactual: %q", i, output[i], line) 32 165 } 33 166 } 34 - if d != line.Data { 35 - t.Errorf("incorrect line data: expected %q, actual %q", line.Data, d) 167 + }) 168 + } 169 + } 170 + 171 + func TestCopyFrom(t *testing.T) { 172 + tests := map[string]struct { 173 + Bytes int64 174 + Offset int64 175 + }{ 176 + "copyAll": { 177 + Bytes: byteBufferSize / 2, 178 + }, 179 + "copyPartial": { 180 + Bytes: byteBufferSize / 2, 181 + Offset: byteBufferSize / 4, 182 + }, 183 + "copyLarge": { 184 + Bytes: 8 * byteBufferSize, 185 + }, 186 + } 187 + 188 + for name, test := range tests { 189 + t.Run(name, func(t *testing.T) { 190 + data := make([]byte, test.Bytes) 191 + rand.Read(data) 192 + 193 + var dst bytes.Buffer 194 + n, err := copyFrom(&dst, bytes.NewReader(data), test.Offset) 195 + if err != nil { 196 + t.Fatalf("unexpected error copying data: %v", err) 197 + } 198 + if n != test.Bytes-test.Offset { 199 + t.Fatalf("incorrect number of bytes copied: expected %d, actual %d", test.Bytes-test.Offset, n) 200 + } 201 + 202 + expected := data[test.Offset:] 203 + if !bytes.Equal(expected, dst.Bytes()) { 204 + t.Fatalf("incorrect data copied:\nexpected: %v\nactual: %v", expected, dst.Bytes()) 36 205 } 37 - if n != i { 38 - t.Errorf("incorrect line number: expected %d, actual %d", i, n) 206 + }) 207 + } 208 + } 209 + 210 + func TestCopyLinesFrom(t *testing.T) { 211 + tests := map[string]struct { 212 + Lines int64 213 + Offset int64 214 + }{ 215 + "copyAll": { 216 + Lines: lineBufferSize / 2, 217 + }, 218 + "copyPartial": { 219 + Lines: lineBufferSize / 2, 220 + Offset: lineBufferSize / 4, 221 + }, 222 + "copyLarge": { 223 + Lines: 8 * lineBufferSize, 224 + }, 225 + } 226 + 227 + const lineLength = 128 228 + 229 + for name, test := range tests { 230 + t.Run(name, func(t *testing.T) { 231 + data := make([]byte, test.Lines*lineLength) 232 + for i := range data { 233 + data[i] = byte(32 + rand.Intn(95)) // ascii letters, numbers, symbols 234 + if i%lineLength == lineLength-1 { 235 + data[i] = '\n' 236 + } 39 237 } 40 - } 41 - }) 42 238 43 - t.Run("readLineOffset", func(t *testing.T) { 44 - r := NewLineReader(strings.NewReader(content), 10) 239 + var dst bytes.Buffer 240 + n, err := copyLinesFrom(&dst, &lineReaderAt{r: bytes.NewReader(data)}, test.Offset) 241 + if err != nil { 242 + t.Fatalf("unexpected error copying data: %v", err) 243 + } 244 + if n != test.Lines-test.Offset { 245 + t.Fatalf("incorrect number of lines copied: expected %d, actual %d", test.Lines-test.Offset, n) 246 + } 45 247 46 - d, n, err := r.ReadLine() 47 - if err != nil { 48 - t.Fatalf("error reading line: %v", err) 49 - } 50 - if d != "first line\n" { 51 - t.Errorf("incorrect line data: expected %q, actual %q", "first line\n", d) 52 - } 53 - if n != 10 { 54 - t.Errorf("incorrect line number: expected %d, actual %d", 10, n) 55 - } 56 - }) 248 + expected := data[test.Offset*lineLength:] 249 + if !bytes.Equal(expected, dst.Bytes()) { 250 + t.Fatalf("incorrect data copied:\nexpected: %v\nactual: %v", expected, dst.Bytes()) 251 + } 252 + }) 253 + } 57 254 }
+16 -10
gitdiff/parser.go
··· 4 4 package gitdiff 5 5 6 6 import ( 7 + "bufio" 7 8 "fmt" 8 9 "io" 9 10 ) ··· 12 13 // the first file is returned as the second value. If an error occurs while 13 14 // parsing, it returns all files parsed before the error. 14 15 // 15 - // If r is a LineReader or StringReader, it is used directly. Otherwise, it is 16 - // wrapped in a way that may read extra data from the underlying input. 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. 17 19 func Parse(r io.Reader) ([]*File, string, error) { 18 20 p := newParser(r) 19 21 ··· 31 33 if err != nil { 32 34 return files, preamble, err 33 35 } 36 + if len(files) == 0 { 37 + preamble = pre 38 + } 34 39 if file == nil { 35 40 break 36 41 } ··· 48 53 } 49 54 } 50 55 51 - if len(files) == 0 { 52 - preamble = pre 53 - } 54 56 files = append(files, file) 55 57 } 56 58 ··· 69 71 // - if returning an object, advance to the first line after the object 70 72 // - any exported parsing methods must initialize the parser by calling Next() 71 73 74 + type stringReader interface { 75 + ReadString(delim byte) (string, error) 76 + } 77 + 72 78 type parser struct { 73 - r LineReader 79 + r stringReader 74 80 75 81 eof bool 76 82 lineno int64 ··· 78 84 } 79 85 80 86 func newParser(r io.Reader) *parser { 81 - if lr, ok := r.(LineReader); ok { 82 - return &parser{r: lr} 87 + if r, ok := r.(stringReader); ok { 88 + return &parser{r: r} 83 89 } 84 - return &parser{r: NewLineReader(r, 0)} 90 + return &parser{r: bufio.NewReader(r)} 85 91 } 86 92 87 93 // Next advances the parser by one line. It returns any error encountered while ··· 117 123 for i := 0; i < len(p.lines)-1; i++ { 118 124 p.lines[i] = p.lines[i+1] 119 125 } 120 - p.lines[len(p.lines)-1], _, err = p.r.ReadLine() 126 + p.lines[len(p.lines)-1], err = p.r.ReadString('\n') 121 127 return 122 128 } 123 129
+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",
+483
gitdiff/patch_header.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "bufio" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "io/ioutil" 9 + "mime/quotedprintable" 10 + "net/mail" 11 + "strconv" 12 + "strings" 13 + "time" 14 + "unicode" 15 + ) 16 + 17 + const ( 18 + mailHeaderPrefix = "From " 19 + prettyHeaderPrefix = "commit " 20 + mailMinimumHeaderPrefix = "From:" 21 + ) 22 + 23 + // PatchHeader is a parsed version of the preamble content that appears before 24 + // the first diff in a patch. It includes metadata about the patch, such as the 25 + // author and a subject. 26 + type PatchHeader struct { 27 + // The SHA of the commit the patch was generated from. Empty if the SHA is 28 + // not included in the header. 29 + SHA string 30 + 31 + // The author details of the patch. If these details are not included in 32 + // the header, Author is nil and AuthorDate is the zero time. 33 + Author *PatchIdentity 34 + AuthorDate time.Time 35 + 36 + // The committer details of the patch. If these details are not included in 37 + // the header, Committer is nil and CommitterDate is the zero time. 38 + Committer *PatchIdentity 39 + CommitterDate time.Time 40 + 41 + // The title and body of the commit message describing the changes in the 42 + // patch. Empty if no message is included in the header. 43 + Title string 44 + Body string 45 + 46 + // If the preamble looks like an email, ParsePatchHeader will 47 + // remove prefixes such as `Re: ` and `[PATCH v3 5/17]` from the 48 + // Title and place them here. 49 + SubjectPrefix string 50 + 51 + // If the preamble looks like an email, and it contains a `---` 52 + // line, that line will be removed and everything after it will be 53 + // placed in BodyAppendix. 54 + BodyAppendix string 55 + 56 + // All headers completely unparsed 57 + RawHeaders map[string][]string 58 + } 59 + 60 + // Message returns the commit message for the header. The message consists of 61 + // the title and the body separated by an empty line. 62 + func (h *PatchHeader) Message() string { 63 + var msg strings.Builder 64 + if h != nil { 65 + msg.WriteString(h.Title) 66 + if h.Body != "" { 67 + msg.WriteString("\n\n") 68 + msg.WriteString(h.Body) 69 + } 70 + } 71 + return msg.String() 72 + } 73 + 74 + // ParsePatchDate parses a patch date string. It returns the parsed time or an 75 + // error if s has an unknown format. ParsePatchDate supports the iso, rfc, 76 + // short, raw, unix, and default formats (with local variants) used by the 77 + // --date flag in Git. 78 + func ParsePatchDate(s string) (time.Time, error) { 79 + const ( 80 + isoFormat = "2006-01-02 15:04:05 -0700" 81 + isoStrictFormat = "2006-01-02T15:04:05-07:00" 82 + rfc2822Format = "Mon, 2 Jan 2006 15:04:05 -0700" 83 + shortFormat = "2006-01-02" 84 + defaultFormat = "Mon Jan 2 15:04:05 2006 -0700" 85 + defaultLocalFormat = "Mon Jan 2 15:04:05 2006" 86 + ) 87 + 88 + if s == "" { 89 + return time.Time{}, nil 90 + } 91 + 92 + for _, fmt := range []string{ 93 + isoFormat, 94 + isoStrictFormat, 95 + rfc2822Format, 96 + shortFormat, 97 + defaultFormat, 98 + defaultLocalFormat, 99 + } { 100 + if t, err := time.ParseInLocation(fmt, s, time.Local); err == nil { 101 + return t, nil 102 + } 103 + } 104 + 105 + // unix format 106 + if unix, err := strconv.ParseInt(s, 10, 64); err == nil { 107 + return time.Unix(unix, 0), nil 108 + } 109 + 110 + // raw format 111 + if space := strings.IndexByte(s, ' '); space > 0 { 112 + unix, uerr := strconv.ParseInt(s[:space], 10, 64) 113 + zone, zerr := time.Parse("-0700", s[space+1:]) 114 + if uerr == nil && zerr == nil { 115 + return time.Unix(unix, 0).In(zone.Location()), nil 116 + } 117 + } 118 + 119 + return time.Time{}, fmt.Errorf("unknown date format: %s", s) 120 + } 121 + 122 + // A PatchHeaderOption modifies the behavior of ParsePatchHeader. 123 + type PatchHeaderOption func(*patchHeaderOptions) 124 + 125 + // SubjectCleanMode controls how ParsePatchHeader cleans subject lines when 126 + // parsing mail-formatted patches. 127 + type SubjectCleanMode int 128 + 129 + const ( 130 + // SubjectCleanWhitespace removes leading and trailing whitespace. 131 + SubjectCleanWhitespace SubjectCleanMode = iota 132 + 133 + // SubjectCleanAll removes leading and trailing whitespace, leading "Re:", 134 + // "re:", and ":" strings, and leading strings enclosed by '[' and ']'. 135 + // This is the default behavior of git (see `git mailinfo`) and this 136 + // package. 137 + SubjectCleanAll 138 + 139 + // SubjectCleanPatchOnly is the same as SubjectCleanAll, but only removes 140 + // leading strings enclosed by '[' and ']' if they start with "PATCH". 141 + SubjectCleanPatchOnly 142 + ) 143 + 144 + // WithSubjectCleanMode sets the SubjectCleanMode for header parsing. By 145 + // default, uses SubjectCleanAll. 146 + func WithSubjectCleanMode(m SubjectCleanMode) PatchHeaderOption { 147 + return func(opts *patchHeaderOptions) { 148 + opts.subjectCleanMode = m 149 + } 150 + } 151 + 152 + type patchHeaderOptions struct { 153 + subjectCleanMode SubjectCleanMode 154 + } 155 + 156 + // ParsePatchHeader parses the preamble string returned by [Parse] into a 157 + // PatchHeader. Due to the variety of header formats, some fields of the parsed 158 + // PatchHeader may be unset after parsing. 159 + // 160 + // Supported formats are the short, medium, full, fuller, and email pretty 161 + // formats used by `git diff`, `git log`, and `git show` and the UNIX mailbox 162 + // format used by `git format-patch`. 163 + // 164 + // When parsing mail-formatted headers, ParsePatchHeader tries to remove 165 + // email-specific content from the title and body: 166 + // 167 + // - Based on the SubjectCleanMode, remove prefixes like reply markers and 168 + // "[PATCH]" strings from the subject, saving any removed content in the 169 + // SubjectPrefix field. Parsing always discards leading and trailing 170 + // whitespace from the subject line. The default mode is SubjectCleanAll. 171 + // 172 + // - If the body contains a "---" line (3 hyphens), remove that line and any 173 + // content after it from the body and save it in the BodyAppendix field. 174 + // 175 + // ParsePatchHeader tries to process content it does not understand wthout 176 + // returning errors, but will return errors if well-identified content like 177 + // dates or identies uses unknown or invalid formats. 178 + func ParsePatchHeader(header string, options ...PatchHeaderOption) (*PatchHeader, error) { 179 + opts := patchHeaderOptions{ 180 + subjectCleanMode: SubjectCleanAll, // match git defaults 181 + } 182 + for _, optFn := range options { 183 + optFn(&opts) 184 + } 185 + 186 + header = strings.TrimSpace(header) 187 + if header == "" { 188 + return &PatchHeader{}, nil 189 + } 190 + 191 + var firstLine, rest string 192 + if idx := strings.IndexByte(header, '\n'); idx >= 0 { 193 + firstLine = header[:idx] 194 + rest = header[idx+1:] 195 + } else { 196 + firstLine = header 197 + rest = "" 198 + } 199 + 200 + switch { 201 + case strings.HasPrefix(firstLine, mailHeaderPrefix): 202 + return parseHeaderMail(firstLine, strings.NewReader(rest), opts) 203 + 204 + case strings.HasPrefix(firstLine, mailMinimumHeaderPrefix): 205 + // With a minimum header, the first line is part of the actual mail 206 + // content and needs to be parsed as part of the "rest" 207 + return parseHeaderMail("", strings.NewReader(header), opts) 208 + 209 + case strings.HasPrefix(firstLine, prettyHeaderPrefix): 210 + return parseHeaderPretty(firstLine, strings.NewReader(rest)) 211 + } 212 + 213 + return nil, errors.New("unrecognized patch header format") 214 + } 215 + 216 + func parseHeaderPretty(prettyLine string, r io.Reader) (*PatchHeader, error) { 217 + const ( 218 + authorPrefix = "Author:" 219 + commitPrefix = "Commit:" 220 + datePrefix = "Date:" 221 + authorDatePrefix = "AuthorDate:" 222 + commitDatePrefix = "CommitDate:" 223 + ) 224 + 225 + h := &PatchHeader{} 226 + 227 + prettyLine = strings.TrimPrefix(prettyLine, prettyHeaderPrefix) 228 + if i := strings.IndexByte(prettyLine, ' '); i > 0 { 229 + h.SHA = prettyLine[:i] 230 + } else { 231 + h.SHA = prettyLine 232 + } 233 + 234 + s := bufio.NewScanner(r) 235 + for s.Scan() { 236 + line := s.Text() 237 + 238 + // empty line marks end of fields, remaining lines are title/message 239 + if strings.TrimSpace(line) == "" { 240 + break 241 + } 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 + 252 + switch { 253 + case strings.HasPrefix(line, authorPrefix): 254 + u, err := ParsePatchIdentity(line[len(authorPrefix):]) 255 + if err != nil { 256 + return nil, err 257 + } 258 + h.Author = &u 259 + 260 + case strings.HasPrefix(line, commitPrefix): 261 + u, err := ParsePatchIdentity(line[len(commitPrefix):]) 262 + if err != nil { 263 + return nil, err 264 + } 265 + h.Committer = &u 266 + 267 + case strings.HasPrefix(line, datePrefix): 268 + d, err := ParsePatchDate(strings.TrimSpace(line[len(datePrefix):])) 269 + if err != nil { 270 + return nil, err 271 + } 272 + h.AuthorDate = d 273 + 274 + case strings.HasPrefix(line, authorDatePrefix): 275 + d, err := ParsePatchDate(strings.TrimSpace(line[len(authorDatePrefix):])) 276 + if err != nil { 277 + return nil, err 278 + } 279 + h.AuthorDate = d 280 + 281 + case strings.HasPrefix(line, commitDatePrefix): 282 + d, err := ParsePatchDate(strings.TrimSpace(line[len(commitDatePrefix):])) 283 + if err != nil { 284 + return nil, err 285 + } 286 + h.CommitterDate = d 287 + } 288 + } 289 + if s.Err() != nil { 290 + return nil, s.Err() 291 + } 292 + 293 + title, indent := scanMessageTitle(s) 294 + if s.Err() != nil { 295 + return nil, s.Err() 296 + } 297 + h.Title = title 298 + 299 + if title != "" { 300 + // Don't check for an appendix, pretty headers do not contain them 301 + body, _ := scanMessageBody(s, indent, false) 302 + if s.Err() != nil { 303 + return nil, s.Err() 304 + } 305 + h.Body = body 306 + } 307 + 308 + return h, nil 309 + } 310 + 311 + func scanMessageTitle(s *bufio.Scanner) (title string, indent string) { 312 + var b strings.Builder 313 + for i := 0; s.Scan(); i++ { 314 + line := s.Text() 315 + trimLine := strings.TrimSpace(line) 316 + if trimLine == "" { 317 + break 318 + } 319 + 320 + if i == 0 { 321 + if start := strings.IndexFunc(line, func(c rune) bool { return !unicode.IsSpace(c) }); start > 0 { 322 + indent = line[:start] 323 + } 324 + } 325 + if b.Len() > 0 { 326 + b.WriteByte(' ') 327 + } 328 + b.WriteString(trimLine) 329 + } 330 + return b.String(), indent 331 + } 332 + 333 + func scanMessageBody(s *bufio.Scanner, indent string, separateAppendix bool) (string, string) { 334 + // Body and appendix 335 + var body, appendix strings.Builder 336 + c := &body 337 + var empty int 338 + for i := 0; s.Scan(); i++ { 339 + line := s.Text() 340 + 341 + line = strings.TrimRightFunc(line, unicode.IsSpace) 342 + line = strings.TrimPrefix(line, indent) 343 + 344 + if line == "" { 345 + empty++ 346 + continue 347 + } 348 + 349 + // If requested, parse out "appendix" information (often added 350 + // by `git format-patch` and removed by `git am`). 351 + if separateAppendix && c == &body && line == "---" { 352 + c = &appendix 353 + continue 354 + } 355 + 356 + if c.Len() > 0 { 357 + c.WriteByte('\n') 358 + if empty > 0 { 359 + c.WriteByte('\n') 360 + } 361 + } 362 + empty = 0 363 + 364 + c.WriteString(line) 365 + } 366 + return body.String(), appendix.String() 367 + } 368 + 369 + func parseHeaderMail(mailLine string, r io.Reader, opts patchHeaderOptions) (*PatchHeader, error) { 370 + msg, err := mail.ReadMessage(r) 371 + if err != nil { 372 + return nil, err 373 + } 374 + 375 + h := &PatchHeader{} 376 + h.RawHeaders = msg.Header 377 + 378 + if strings.HasPrefix(mailLine, mailHeaderPrefix) { 379 + mailLine = strings.TrimPrefix(mailLine, mailHeaderPrefix) 380 + if i := strings.IndexByte(mailLine, ' '); i > 0 { 381 + h.SHA = mailLine[:i] 382 + } 383 + } 384 + 385 + from := msg.Header.Get("From") 386 + if from != "" { 387 + u, err := ParsePatchIdentity(from) 388 + if err != nil { 389 + return nil, err 390 + } 391 + h.Author = &u 392 + } 393 + 394 + date := msg.Header.Get("Date") 395 + if date != "" { 396 + d, err := ParsePatchDate(date) 397 + if err != nil { 398 + return nil, err 399 + } 400 + h.AuthorDate = d 401 + } 402 + 403 + subject := msg.Header.Get("Subject") 404 + h.SubjectPrefix, h.Title = cleanSubject(subject, opts.subjectCleanMode) 405 + 406 + s := bufio.NewScanner(msg.Body) 407 + h.Body, h.BodyAppendix = scanMessageBody(s, "", true) 408 + if s.Err() != nil { 409 + return nil, s.Err() 410 + } 411 + 412 + return h, nil 413 + } 414 + 415 + func cleanSubject(s string, mode SubjectCleanMode) (prefix string, subject string) { 416 + switch mode { 417 + case SubjectCleanAll, SubjectCleanPatchOnly: 418 + case SubjectCleanWhitespace: 419 + return "", strings.TrimSpace(decodeSubject(s)) 420 + default: 421 + panic(fmt.Sprintf("unknown clean mode: %d", mode)) 422 + } 423 + 424 + // Based on the algorithm from Git in mailinfo.c:cleanup_subject() 425 + // If compatibility with `git am` drifts, go there to see if there are any updates. 426 + 427 + at := 0 428 + for at < len(s) { 429 + switch s[at] { 430 + case 'r', 'R': 431 + // Detect re:, Re:, rE: and RE: 432 + if at+2 < len(s) && (s[at+1] == 'e' || s[at+1] == 'E') && s[at+2] == ':' { 433 + at += 3 434 + continue 435 + } 436 + 437 + case ' ', '\t', ':': 438 + // Delete whitespace and duplicate ':' characters 439 + at++ 440 + continue 441 + 442 + case '[': 443 + if i := strings.IndexByte(s[at:], ']'); i > 0 { 444 + if mode == SubjectCleanAll || strings.Contains(s[at:at+i+1], "PATCH") { 445 + at += i + 1 446 + continue 447 + } 448 + } 449 + } 450 + 451 + // Nothing was removed, end processing 452 + break 453 + } 454 + 455 + prefix = strings.TrimLeftFunc(s[:at], unicode.IsSpace) 456 + subject = strings.TrimRightFunc(decodeSubject(s[at:]), unicode.IsSpace) 457 + return 458 + } 459 + 460 + // Decodes a subject line. Currently only supports quoted-printable UTF-8. This format is the result 461 + // of a `git format-patch` when the commit title has a non-ASCII character (i.e. an emoji). 462 + // See for reference: https://stackoverflow.com/questions/27695749/gmail-api-not-respecting-utf-encoding-in-subject 463 + func decodeSubject(encoded string) string { 464 + if !strings.HasPrefix(encoded, "=?UTF-8?q?") { 465 + // not UTF-8 encoded 466 + return encoded 467 + } 468 + 469 + // If the subject is too long, `git format-patch` may produce a subject line across 470 + // multiple lines. When parsed, this can look like the following: 471 + // <UTF8-prefix><first-line> <UTF8-prefix><second-line> 472 + payload := " " + encoded 473 + payload = strings.ReplaceAll(payload, " =?UTF-8?q?", "") 474 + payload = strings.ReplaceAll(payload, "?=", "") 475 + 476 + decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(payload))) 477 + if err != nil { 478 + // if err, abort decoding and return original subject 479 + return encoded 480 + } 481 + 482 + return string(decoded) 483 + }
+590
gitdiff/patch_header_test.go
··· 1 + package gitdiff 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + ) 7 + 8 + func TestParsePatchDate(t *testing.T) { 9 + expected := time.Date(2020, 4, 9, 8, 7, 6, 0, time.UTC) 10 + 11 + tests := map[string]struct { 12 + Input string 13 + Output time.Time 14 + Err interface{} 15 + }{ 16 + "default": { 17 + Input: "Thu Apr 9 01:07:06 2020 -0700", 18 + Output: expected, 19 + }, 20 + "defaultLocal": { 21 + Input: "Thu Apr 9 01:07:06 2020", 22 + Output: time.Date(2020, 4, 9, 1, 7, 6, 0, time.Local), 23 + }, 24 + "iso": { 25 + Input: "2020-04-09 01:07:06 -0700", 26 + Output: expected, 27 + }, 28 + "isoStrict": { 29 + Input: "2020-04-09T01:07:06-07:00", 30 + Output: expected, 31 + }, 32 + "rfc": { 33 + Input: "Thu, 9 Apr 2020 01:07:06 -0700", 34 + Output: expected, 35 + }, 36 + "short": { 37 + Input: "2020-04-09", 38 + Output: time.Date(2020, 4, 9, 0, 0, 0, 0, time.Local), 39 + }, 40 + "raw": { 41 + Input: "1586419626 -0700", 42 + Output: expected, 43 + }, 44 + "unix": { 45 + Input: "1586419626", 46 + Output: expected, 47 + }, 48 + "unknownFormat": { 49 + Input: "4/9/2020 01:07:06 PDT", 50 + Err: "unknown date format", 51 + }, 52 + "empty": { 53 + Input: "", 54 + }, 55 + } 56 + 57 + for name, test := range tests { 58 + t.Run(name, func(t *testing.T) { 59 + d, err := ParsePatchDate(test.Input) 60 + if test.Err != nil { 61 + assertError(t, test.Err, err, "parsing date") 62 + return 63 + } 64 + if err != nil { 65 + t.Fatalf("unexpected error parsing date: %v", err) 66 + } 67 + if !test.Output.Equal(d) { 68 + t.Errorf("incorrect parsed date: expected %v, actual %v", test.Output, d) 69 + } 70 + }) 71 + } 72 + } 73 + 74 + func TestParsePatchHeader(t *testing.T) { 75 + expectedSHA := "61f5cd90bed4d204ee3feb3aa41ee91d4734855b" 76 + expectedIdentity := &PatchIdentity{ 77 + Name: "Morton Haypenny", 78 + Email: "mhaypenny@example.com", 79 + } 80 + expectedDate := time.Date(2020, 04, 11, 15, 21, 23, 0, time.FixedZone("PDT", -7*60*60)) 81 + expectedTitle := "A sample commit to test header parsing" 82 + expectedEmojiOneLineTitle := "๐Ÿค– Enabling auto-merging" 83 + expectedEmojiMultiLineTitle := "[IA64] Put ia64 config files on the Uwe Kleine-Kรถnig diet" 84 + expectedBody := "The medium format shows the body, which\nmay wrap on to multiple lines.\n\nAnother body line." 85 + expectedBodyAppendix := "CC: Joe Smith <joe.smith@company.com>" 86 + 87 + tests := map[string]struct { 88 + Input string 89 + Options []PatchHeaderOption 90 + Header PatchHeader 91 + Err interface{} 92 + }{ 93 + "prettyShort": { 94 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 95 + Author: Morton Haypenny <mhaypenny@example.com> 96 + 97 + A sample commit to test header parsing 98 + `, 99 + Header: PatchHeader{ 100 + SHA: expectedSHA, 101 + Author: expectedIdentity, 102 + Title: expectedTitle, 103 + }, 104 + }, 105 + "prettyMedium": { 106 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 107 + Author: Morton Haypenny <mhaypenny@example.com> 108 + Date: Sat Apr 11 15:21:23 2020 -0700 109 + 110 + A sample commit to test header parsing 111 + 112 + The medium format shows the body, which 113 + may wrap on to multiple lines. 114 + 115 + Another body line. 116 + `, 117 + Header: PatchHeader{ 118 + SHA: expectedSHA, 119 + Author: expectedIdentity, 120 + AuthorDate: expectedDate, 121 + Title: expectedTitle, 122 + Body: expectedBody, 123 + }, 124 + }, 125 + "prettyFull": { 126 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 127 + Author: Morton Haypenny <mhaypenny@example.com> 128 + Commit: Morton Haypenny <mhaypenny@example.com> 129 + 130 + A sample commit to test header parsing 131 + 132 + The medium format shows the body, which 133 + may wrap on to multiple lines. 134 + 135 + Another body line. 136 + `, 137 + Header: PatchHeader{ 138 + SHA: expectedSHA, 139 + Author: expectedIdentity, 140 + Committer: expectedIdentity, 141 + Title: expectedTitle, 142 + Body: expectedBody, 143 + }, 144 + }, 145 + "prettyFuller": { 146 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 147 + Author: Morton Haypenny <mhaypenny@example.com> 148 + AuthorDate: Sat Apr 11 15:21:23 2020 -0700 149 + Commit: Morton Haypenny <mhaypenny@example.com> 150 + CommitDate: Sat Apr 11 15:21:23 2020 -0700 151 + 152 + A sample commit to test header parsing 153 + 154 + The medium format shows the body, which 155 + may wrap on to multiple lines. 156 + 157 + Another body line. 158 + `, 159 + Header: PatchHeader{ 160 + SHA: expectedSHA, 161 + Author: expectedIdentity, 162 + AuthorDate: expectedDate, 163 + Committer: expectedIdentity, 164 + CommitterDate: expectedDate, 165 + Title: expectedTitle, 166 + Body: expectedBody, 167 + }, 168 + }, 169 + "prettyAppendix": { 170 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 171 + Author: Morton Haypenny <mhaypenny@example.com> 172 + AuthorDate: Sat Apr 11 15:21:23 2020 -0700 173 + Commit: Morton Haypenny <mhaypenny@example.com> 174 + CommitDate: Sat Apr 11 15:21:23 2020 -0700 175 + 176 + A sample commit to test header parsing 177 + 178 + The medium format shows the body, which 179 + may wrap on to multiple lines. 180 + 181 + Another body line. 182 + --- 183 + CC: Joe Smith <joe.smith@company.com> 184 + `, 185 + Header: PatchHeader{ 186 + SHA: expectedSHA, 187 + Author: expectedIdentity, 188 + AuthorDate: expectedDate, 189 + Committer: expectedIdentity, 190 + CommitterDate: expectedDate, 191 + Title: expectedTitle, 192 + Body: expectedBody + "\n---\n" + expectedBodyAppendix, 193 + }, 194 + }, 195 + "mailbox": { 196 + Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001 197 + From: Morton Haypenny <mhaypenny@example.com> 198 + Date: Sat, 11 Apr 2020 15:21:23 -0700 199 + Subject: [PATCH] A sample commit to test header parsing 200 + 201 + The medium format shows the body, which 202 + may wrap on to multiple lines. 203 + 204 + Another body line. 205 + `, 206 + Header: PatchHeader{ 207 + SHA: expectedSHA, 208 + Author: expectedIdentity, 209 + AuthorDate: expectedDate, 210 + Title: expectedTitle, 211 + Body: expectedBody, 212 + }, 213 + }, 214 + "mailboxPatchOnly": { 215 + Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001 216 + From: Morton Haypenny <mhaypenny@example.com> 217 + Date: Sat, 11 Apr 2020 15:21:23 -0700 218 + Subject: [PATCH] [BUG-123] A sample commit to test header parsing 219 + 220 + The medium format shows the body, which 221 + may wrap on to multiple lines. 222 + 223 + Another body line. 224 + `, 225 + Options: []PatchHeaderOption{ 226 + WithSubjectCleanMode(SubjectCleanPatchOnly), 227 + }, 228 + Header: PatchHeader{ 229 + SHA: expectedSHA, 230 + Author: expectedIdentity, 231 + AuthorDate: expectedDate, 232 + Title: "[BUG-123] " + expectedTitle, 233 + Body: expectedBody, 234 + }, 235 + }, 236 + "mailboxEmojiOneLine": { 237 + Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001 238 + From: Morton Haypenny <mhaypenny@example.com> 239 + Date: Sat, 11 Apr 2020 15:21:23 -0700 240 + Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20Enabling=20auto-merging?= 241 + 242 + The medium format shows the body, which 243 + may wrap on to multiple lines. 244 + 245 + Another body line. 246 + `, 247 + Header: PatchHeader{ 248 + SHA: expectedSHA, 249 + Author: expectedIdentity, 250 + AuthorDate: expectedDate, 251 + Title: expectedEmojiOneLineTitle, 252 + Body: expectedBody, 253 + }, 254 + }, 255 + "mailboxEmojiMultiLine": { 256 + Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001 257 + From: Morton Haypenny <mhaypenny@example.com> 258 + Date: Sat, 11 Apr 2020 15:21:23 -0700 259 + Subject: [PATCH] =?UTF-8?q?[IA64]=20Put=20ia64=20config=20files=20on=20the=20?= 260 + =?UTF-8?q?Uwe=20Kleine-K=C3=B6nig=20diet?= 261 + 262 + The medium format shows the body, which 263 + may wrap on to multiple lines. 264 + 265 + Another body line. 266 + `, 267 + Header: PatchHeader{ 268 + SHA: expectedSHA, 269 + Author: expectedIdentity, 270 + AuthorDate: expectedDate, 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, 294 + Body: expectedBody, 295 + }, 296 + }, 297 + "mailboxAppendix": { 298 + Input: `From 61f5cd90bed4d204ee3feb3aa41ee91d4734855b Mon Sep 17 00:00:00 2001 299 + From: Morton Haypenny <mhaypenny@example.com> 300 + Date: Sat, 11 Apr 2020 15:21:23 -0700 301 + Subject: [PATCH] A sample commit to test header parsing 302 + 303 + The medium format shows the body, which 304 + may wrap on to multiple lines. 305 + 306 + Another body line. 307 + --- 308 + CC: Joe Smith <joe.smith@company.com> 309 + `, 310 + Header: PatchHeader{ 311 + SHA: expectedSHA, 312 + Author: expectedIdentity, 313 + AuthorDate: expectedDate, 314 + Title: expectedTitle, 315 + Body: expectedBody, 316 + BodyAppendix: expectedBodyAppendix, 317 + }, 318 + }, 319 + "mailboxMinimalNoName": { 320 + Input: `From: <mhaypenny@example.com> 321 + Subject: [PATCH] A sample commit to test header parsing 322 + 323 + The medium format shows the body, which 324 + may wrap on to multiple lines. 325 + 326 + Another body line. 327 + `, 328 + Header: PatchHeader{ 329 + Author: &PatchIdentity{expectedIdentity.Email, expectedIdentity.Email}, 330 + Title: expectedTitle, 331 + Body: expectedBody, 332 + }, 333 + }, 334 + "mailboxMinimal": { 335 + Input: `From: Morton Haypenny <mhaypenny@example.com> 336 + Subject: [PATCH] A sample commit to test header parsing 337 + 338 + The medium format shows the body, which 339 + may wrap on to multiple lines. 340 + 341 + Another body line. 342 + `, 343 + Header: PatchHeader{ 344 + Author: expectedIdentity, 345 + Title: expectedTitle, 346 + Body: expectedBody, 347 + }, 348 + }, 349 + "unwrapTitle": { 350 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 351 + Author: Morton Haypenny <mhaypenny@example.com> 352 + Date: Sat Apr 11 15:21:23 2020 -0700 353 + 354 + A sample commit to test header parsing with a long 355 + title that is wrapped. 356 + `, 357 + Header: PatchHeader{ 358 + SHA: expectedSHA, 359 + Author: expectedIdentity, 360 + AuthorDate: expectedDate, 361 + Title: expectedTitle + " with a long title that is wrapped.", 362 + }, 363 + }, 364 + "normalizeBodySpace": { 365 + Input: `commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 366 + Author: Morton Haypenny <mhaypenny@example.com> 367 + Date: Sat Apr 11 15:21:23 2020 -0700 368 + 369 + A sample commit to test header parsing 370 + 371 + 372 + The medium format shows the body, which 373 + may wrap on to multiple lines. 374 + 375 + 376 + Another body line. 377 + 378 + 379 + `, 380 + Header: PatchHeader{ 381 + SHA: expectedSHA, 382 + Author: expectedIdentity, 383 + AuthorDate: expectedDate, 384 + Title: expectedTitle, 385 + Body: expectedBody, 386 + }, 387 + }, 388 + "ignoreLeadingBlankLines": { 389 + Input: ` 390 + 391 + ` + " " + ` 392 + commit 61f5cd90bed4d204ee3feb3aa41ee91d4734855b 393 + Author: Morton Haypenny <mhaypenny@example.com> 394 + 395 + A sample commit to test header parsing 396 + `, 397 + Header: PatchHeader{ 398 + SHA: expectedSHA, 399 + Author: expectedIdentity, 400 + Title: expectedTitle, 401 + }, 402 + }, 403 + "emptyHeader": { 404 + Input: "", 405 + Header: PatchHeader{}, 406 + }, 407 + } 408 + 409 + for name, test := range tests { 410 + t.Run(name, func(t *testing.T) { 411 + h, err := ParsePatchHeader(test.Input, test.Options...) 412 + if test.Err != nil { 413 + assertError(t, test.Err, err, "parsing patch header") 414 + return 415 + } 416 + if err != nil { 417 + t.Fatalf("unexpected error parsing patch header: %v", err) 418 + } 419 + if h == nil { 420 + t.Fatalf("expected non-nil header, but got nil") 421 + } 422 + 423 + exp := test.Header 424 + act := *h 425 + 426 + if exp.SHA != act.SHA { 427 + t.Errorf("incorrect parsed SHA: expected %q, actual %q", exp.SHA, act.SHA) 428 + } 429 + 430 + assertPatchIdentity(t, "author", exp.Author, act.Author) 431 + if !exp.AuthorDate.Equal(act.AuthorDate) { 432 + t.Errorf("incorrect parsed author date: expected %v, but got %v", exp.AuthorDate, act.AuthorDate) 433 + } 434 + 435 + assertPatchIdentity(t, "committer", exp.Committer, act.Committer) 436 + if !exp.CommitterDate.Equal(act.CommitterDate) { 437 + t.Errorf("incorrect parsed committer date: expected %v, but got %v", exp.CommitterDate, act.CommitterDate) 438 + } 439 + 440 + if exp.Title != act.Title { 441 + t.Errorf("incorrect parsed title:\n expected: %q\n actual: %q", exp.Title, act.Title) 442 + } 443 + if exp.Body != act.Body { 444 + t.Errorf("incorrect parsed body:\n expected: %q\n actual: %q", exp.Body, act.Body) 445 + } 446 + if exp.BodyAppendix != act.BodyAppendix { 447 + t.Errorf("incorrect parsed body appendix:\n expected: %q\n actual: %q", 448 + exp.BodyAppendix, act.BodyAppendix) 449 + } 450 + }) 451 + } 452 + } 453 + 454 + func assertPatchIdentity(t *testing.T, kind string, exp, act *PatchIdentity) { 455 + switch { 456 + case exp == nil && act == nil: 457 + case exp == nil && act != nil: 458 + t.Errorf("incorrect parsed %s: expected nil, but got %+v", kind, act) 459 + case exp != nil && act == nil: 460 + t.Errorf("incorrect parsed %s: expected %+v, but got nil", kind, exp) 461 + case exp.Name != act.Name || exp.Email != act.Email: 462 + t.Errorf("incorrect parsed %s, expected %+v, bot got %+v", kind, exp, act) 463 + } 464 + } 465 + 466 + func TestCleanSubject(t *testing.T) { 467 + expectedSubject := "A sample commit to test header parsing" 468 + 469 + tests := map[string]struct { 470 + Input string 471 + Mode SubjectCleanMode 472 + Prefix string 473 + Subject string 474 + }{ 475 + "CleanAll/noPrefix": { 476 + Input: expectedSubject, 477 + Mode: SubjectCleanAll, 478 + Subject: expectedSubject, 479 + }, 480 + "CleanAll/patchPrefix": { 481 + Input: "[PATCH] " + expectedSubject, 482 + Mode: SubjectCleanAll, 483 + Prefix: "[PATCH] ", 484 + Subject: expectedSubject, 485 + }, 486 + "CleanAll/patchPrefixNoSpace": { 487 + Input: "[PATCH]" + expectedSubject, 488 + Mode: SubjectCleanAll, 489 + Prefix: "[PATCH]", 490 + Subject: expectedSubject, 491 + }, 492 + "CleanAll/patchPrefixContent": { 493 + Input: "[PATCH 3/7] " + expectedSubject, 494 + Mode: SubjectCleanAll, 495 + Prefix: "[PATCH 3/7] ", 496 + Subject: expectedSubject, 497 + }, 498 + "CleanAll/spacePrefix": { 499 + Input: " " + expectedSubject, 500 + Mode: SubjectCleanAll, 501 + Subject: expectedSubject, 502 + }, 503 + "CleanAll/replyLowerPrefix": { 504 + Input: "re: " + expectedSubject, 505 + Mode: SubjectCleanAll, 506 + Prefix: "re: ", 507 + Subject: expectedSubject, 508 + }, 509 + "CleanAll/replyMixedPrefix": { 510 + Input: "Re: " + expectedSubject, 511 + Mode: SubjectCleanAll, 512 + Prefix: "Re: ", 513 + Subject: expectedSubject, 514 + }, 515 + "CleanAll/replyCapsPrefix": { 516 + Input: "RE: " + expectedSubject, 517 + Mode: SubjectCleanAll, 518 + Prefix: "RE: ", 519 + Subject: expectedSubject, 520 + }, 521 + "CleanAll/replyDoublePrefix": { 522 + Input: "Re: re: " + expectedSubject, 523 + Mode: SubjectCleanAll, 524 + Prefix: "Re: re: ", 525 + Subject: expectedSubject, 526 + }, 527 + "CleanAll/noPrefixSubjectHasRe": { 528 + Input: "Reimplement parsing", 529 + Mode: SubjectCleanAll, 530 + Subject: "Reimplement parsing", 531 + }, 532 + "CleanAll/patchPrefixSubjectHasRe": { 533 + Input: "[PATCH 1/2] Reimplement parsing", 534 + Mode: SubjectCleanAll, 535 + Prefix: "[PATCH 1/2] ", 536 + Subject: "Reimplement parsing", 537 + }, 538 + "CleanAll/unclosedPrefix": { 539 + Input: "[Just to annoy people", 540 + Mode: SubjectCleanAll, 541 + Subject: "[Just to annoy people", 542 + }, 543 + "CleanAll/multiplePrefix": { 544 + Input: " Re:Re: [PATCH 1/2][DRAFT] " + expectedSubject + " ", 545 + Mode: SubjectCleanAll, 546 + Prefix: "Re:Re: [PATCH 1/2][DRAFT] ", 547 + Subject: expectedSubject, 548 + }, 549 + "CleanPatchOnly/patchPrefix": { 550 + Input: "[PATCH] " + expectedSubject, 551 + Mode: SubjectCleanPatchOnly, 552 + Prefix: "[PATCH] ", 553 + Subject: expectedSubject, 554 + }, 555 + "CleanPatchOnly/mixedPrefix": { 556 + Input: "[PATCH] [TICKET-123] " + expectedSubject, 557 + Mode: SubjectCleanPatchOnly, 558 + Prefix: "[PATCH] ", 559 + Subject: "[TICKET-123] " + expectedSubject, 560 + }, 561 + "CleanPatchOnly/multiplePrefix": { 562 + Input: "Re:Re: [PATCH 1/2][DRAFT] " + expectedSubject, 563 + Mode: SubjectCleanPatchOnly, 564 + Prefix: "Re:Re: [PATCH 1/2]", 565 + Subject: "[DRAFT] " + expectedSubject, 566 + }, 567 + "CleanWhitespace/leadingSpace": { 568 + Input: " [PATCH] " + expectedSubject, 569 + Mode: SubjectCleanWhitespace, 570 + Subject: "[PATCH] " + expectedSubject, 571 + }, 572 + "CleanWhitespace/trailingSpace": { 573 + Input: "[PATCH] " + expectedSubject + " ", 574 + Mode: SubjectCleanWhitespace, 575 + Subject: "[PATCH] " + expectedSubject, 576 + }, 577 + } 578 + 579 + for name, test := range tests { 580 + t.Run(name, func(t *testing.T) { 581 + prefix, subject := cleanSubject(test.Input, test.Mode) 582 + if prefix != test.Prefix { 583 + t.Errorf("incorrect prefix: expected %q, actual %q", test.Prefix, prefix) 584 + } 585 + if subject != test.Subject { 586 + t.Errorf("incorrect subject: expected %q, actual %q", test.Subject, subject) 587 + } 588 + }) 589 + } 590 + }
+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
··· 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 + }
+124
gitdiff/testdata/apply/bin.go
··· 1 + //go:build ignore 2 + 3 + // bin.go is a helper CLI to manipulate binary diff data for testing purposes. 4 + // It can decode patches generated by git using the standard parsing functions 5 + // or it can encode binary data back into the format expected by Git. It 6 + // operates on stdin writes results (possibly binary) to stdout. 7 + 8 + package main 9 + 10 + import ( 11 + "bytes" 12 + "compress/zlib" 13 + "encoding/binary" 14 + "flag" 15 + "io/ioutil" 16 + "log" 17 + "os" 18 + "strings" 19 + 20 + "github.com/bluekeyes/go-gitdiff/gitdiff" 21 + ) 22 + 23 + var ( 24 + b85Powers = []uint32{52200625, 614125, 7225, 85, 1} 25 + b85Alpha = []byte( 26 + "0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "!#$%&()*+-;<=>?@^_`{|}~", 27 + ) 28 + ) 29 + 30 + var mode string 31 + 32 + func base85Encode(data []byte) []byte { 33 + chunks, remaining := len(data)/4, len(data)%4 34 + if remaining > 0 { 35 + data = append(data, make([]byte, 4-remaining)...) 36 + chunks++ 37 + } 38 + 39 + var n int 40 + out := make([]byte, 5*chunks) 41 + 42 + for i := 0; i < len(data); i += 4 { 43 + v := binary.BigEndian.Uint32(data[i : i+4]) 44 + for j := 0; j < 5; j++ { 45 + p := v / b85Powers[j] 46 + out[n+j] = b85Alpha[p] 47 + v -= b85Powers[j] * p 48 + } 49 + n += 5 50 + } 51 + 52 + return out 53 + } 54 + 55 + func compress(data []byte) ([]byte, error) { 56 + var b bytes.Buffer 57 + w := zlib.NewWriter(&b) 58 + 59 + if _, err := w.Write(data); err != nil { 60 + return nil, err 61 + } 62 + if err := w.Close(); err != nil { 63 + return nil, err 64 + } 65 + 66 + return b.Bytes(), nil 67 + } 68 + 69 + func wrap(data []byte) string { 70 + var s strings.Builder 71 + for i := 0; i < len(data); i += 52 { 72 + c := 52 73 + if c > len(data)-i { 74 + c = len(data) - i 75 + } 76 + b := (c / 5) * 4 77 + 78 + if b <= 26 { 79 + s.WriteByte(byte('A' + b - 1)) 80 + } else { 81 + s.WriteByte(byte('a' + b - 27)) 82 + } 83 + s.Write(data[i : i+c]) 84 + s.WriteByte('\n') 85 + } 86 + return s.String() 87 + } 88 + 89 + func init() { 90 + flag.StringVar(&mode, "mode", "parse", "operation mode, one of 'parse' or 'encode'") 91 + } 92 + 93 + func main() { 94 + flag.Parse() 95 + 96 + switch mode { 97 + case "parse": 98 + files, _, err := gitdiff.Parse(os.Stdin) 99 + if err != nil { 100 + log.Fatalf("failed to parse file: %v", err) 101 + } 102 + if len(files) != 1 { 103 + log.Fatalf("patch contains more than one file: %d", len(files)) 104 + } 105 + if files[0].BinaryFragment == nil { 106 + log.Fatalf("patch file does not contain a binary fragment") 107 + } 108 + os.Stdout.Write(files[0].BinaryFragment.Data) 109 + 110 + case "encode": 111 + data, err := ioutil.ReadAll(os.Stdin) 112 + if err != nil { 113 + log.Fatalf("failed to read input: %v", err) 114 + } 115 + data, err = compress(data) 116 + if err != nil { 117 + log.Fatalf("failed to compress data: %v", err) 118 + } 119 + os.Stdout.WriteString(wrap(base85Encode(data))) 120 + 121 + default: 122 + log.Fatalf("unknown mode: %s", mode) 123 + } 124 + }
gitdiff/testdata/apply/bin_fragment_delta_error.src

This is a binary file and will not be displayed.

+5
gitdiff/testdata/apply/bin_fragment_delta_error_dst_size.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src 2 + GIT binary patch 3 + delta 18 4 + fc${itY+{<=z`_4AtEhVK$zKyatN;N30RR6$D+j^= 5 +
+5
gitdiff/testdata/apply/bin_fragment_delta_error_incomplete_add.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src 2 + GIT binary patch 3 + delta 11 4 + Xc${itY+{_?z`_4As|XMP0RR6K8UwQc 5 +
+5
gitdiff/testdata/apply/bin_fragment_delta_error_incomplete_copy.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src 2 + GIT binary patch 3 + delta 17 4 + fc${itY+{_?z`_4AtEhVK$zKya00961|Nl5!2ZsOv 5 +
+5
gitdiff/testdata/apply/bin_fragment_delta_error_src_size.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src 2 + GIT binary patch 3 + delta 18 4 + fc${itYGRz=z`_4AtEhVK$zKyatN;N30RR6$EeFB? 5 +
gitdiff/testdata/apply/bin_fragment_delta_modify.out

This is a binary file and will not be displayed.

+13
gitdiff/testdata/apply/bin_fragment_delta_modify.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_modify.src b/gitdiff/testdata/apply/bin_fragment_delta_modify.src 2 + GIT binary patch 3 + delta 172 4 + zcmV;d08{^f2)qc8AP{I3VQ>J`s>wb0HU+h#6w8q?tUO~cHmDjZi2<8yZ9XmKhhMdo 5 + zWu(4bg|8QwzZ|1e*rL4P#)`Fen<n~ik=E?$qG6?hzJ6$u{l5W#?uwHb0q6w)00000 6 + zlLZ3%0RfW%1N%UMJ{~Z~0@X${&1Kk#98tb3==a{J7A;`O`v&<T@514_mvMTz72b#n 7 + atf$#NLoPbNe?RPFJVt1aCFGoQbiKD!OHgJ2 8 + 9 + delta 112 10 + zcmV-$0FVE?2!IHXAP~DY<7&llQfwqYA%tL<sR@xVtUMD;+4ZG>XTQ5=J2y;^BfB}4 11 + zWkisH791|vOVl5e-@^VLX0s~Ky_UyN!3;CgPr>Edj0j+0gOSwSsFsr$0q6zUJph<q 12 + SlLZ3%0XmZb1N#I__7UCuR5Dxu 13 +
gitdiff/testdata/apply/bin_fragment_delta_modify.src

This is a binary file and will not be displayed.

gitdiff/testdata/apply/bin_fragment_delta_modify_large.out

This is a binary file and will not be displayed.

+166
gitdiff/testdata/apply/bin_fragment_delta_modify_large.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_modify_large.src b/gitdiff/testdata/apply/bin_fragment_delta_modify_large.src 2 + GIT binary patch 3 + delta 4145 4 + zcmV-15YF#_fCzwq2!NCU6n}UD@BJ(nwumRXTBWU&(r44U#UTR?Hg2j#E9tcD8>JCY 5 + zqz<L)JrNXpEfw|b8Ts&UEkO5+T?jiM$x7@_P2&_*&`$=fcypBBj#$(pjJ9|+w!nF; 6 + z*sgN!^;$QEf?3|e>)g`N&ViW9W6D7kX7GX{y{m=JvFi1-r`PEE?SE&^2StK}qz5hC 7 + zom0ywdCWuNb#YXdd0%FMNHft!FTER$>uzu5gxdoGBy789raJBW7jAhN2TWFL{P%2l 8 + z|AX{}Jz8U}Y*X|~=?4<;F4)94!-e?w)#D0h8n1_ORWNCOC&7=!U0MP3<v=_v9C5!i 9 + z0Q+7#pUXRc?6%;cFn?F`n#;%_dGwni+^Ev;mF)3qj28^tfxPMqDV12HP;vJ_p9F#w 10 + z{re$r!khX1Grt6PEg!>BI0~M)pZ-cf6aFkVFzO&JOkcv7FeEq|)DcBDAP&_&ZgBk* 11 + zVp(I^5-bN3L{~g{bHnkWX%0Hj02~njkKX8Zz%Ih#=LBD%Pk%TkW4ize_HweE#@_-2 12 + zvpyE#e@^n#rRGx;O84LB3bMdrxdv%Gkc)ZQq%8pkxT9b*)}Z&t5bibZ0)8H8T33vN 13 + zgTj)j_%wz13x+TZ6LgdupD^ke2!n7E-YZ%8n3OTzK5*T(BH>ltfU|QJ7VTAu<e}on 14 + z$=JJjjciFFN`Fzsxf-MARHfr7D9c#x?{62mGRmgzrR~VD+Ec3xhX?ouWpC(VJF31{ 15 + zSg`N(Bv@0v75=Jj5L%(L+)rQK7Yqo!ZI;@6S<+%;8n*To-C%h>r}ah?0sC%ZUY?<b 16 + zv(0AQ|7cLp<eKKWxX)Y#(4*sOJ(f!elXc4>&xwkEUw@Y9LqO1~eF>>5cDlg_YxuF| 17 + zZ58JVlu7IBfP1@TDmG;<{X>(&*Hy<j=OVk<Z{4Xw!q_uh3PF;mQ=%{@DP($Ym^O7u 18 + z()CP&Lg$k9$86TRX~S*o)PMp4ltJ3>C-7zqRrY~#3W>hP8@a&jwYi_$k(j`D$Ta97 19 + z{(rL4B7Ysa#+5QC40p?w_V}r&G%{W$)8R1P`wet5K>`4D&EanFW{d2mOB<F_6i4{2 20 + zyvNjwqpz2xNywg~atQE~7i+4HXtp*BZ=iZbxqB9%ln@_KVIKuO(nLG^6V%DDaJYn* 21 + z=!{Jo%`{rQ1*~_S3Xt4d{uL-EROqk|1qV~gXMd@K!*vHg@UsOgfcNC~T%s^yNa-mE 22 + zWwvGu5f1Z&pe6o5bGG4F@r5(pTx6;)9Ly8(reL*<OVspK=%BY6Le~m;0dvr`0#=GZ 23 + zlA<Vi?w~V5RN|C0iy|D|@2CGptYlBJMz31?1p!JP^z~%w&3`BBU&UCo@6HFkrK-3O 24 + z^M6Vy;CO*>vm~hJviTXh@a)Qp^TpF9X}vEVN-{LA%5X<MH)9ZCktsL5)O9)!?Qy`S 25 + zDd4*FDIj>p^*>%23c&{6HgPH4ahb@257yLNuno+(_9IJ!cgw97b*@K~X-9f8rdsd` 26 + zOSj362)!mBsX%<QkLK_a4;5C`Hw&r*C4c$CJwCk3vNJG1z*ZUK_s-To!tlwRObW19 27 + zUka<y!0g)DN^Vs$_rw9OJP`AO*l3*zd7z+1UHYUg7uw9?U+{6;pts4(?Z!@3C6;>A 28 + zQ*StV4WzVHG#+*IwPf7@BwNAUN{Tre0&7KHK8Gq6W2Jeqe%S(>+{1HM$cFn>J%6m& 29 + z|8$kpBb=Dxh6$MwDCzl1g;Y`~f7F-wEny-#iI1X?l(D;Z$PLR3jay0@l4S5PR8fS< 30 + zi+>Hek8mDV9ItDt3}cF;v-yN79ZUwT1^LyF1n$Cy%gt_C&r9r*A*)&$ZFl<eghAvH 31 + zRkrj-L9@*P`M#b14DP@YWqKrGihs`_$9}?b&`hgRiub41qZ?7&Nh%}HpP3Tbttvv5 32 + zq=W1Nc?DQo<|}6{xbpW%2@<O6f0JU{G6JrwI_yfvDsdp$CDKVJe{UP|{F2$LMyq;S 33 + z;6*a3u>HnVf=mg#Q%sK{dyD8$2z{ndd#G^c?_~0mb!M$7ZxU$373E=vV}GR5OrWDm 34 + zBsRhxx$#Ql_U#b2h}uPg{qm53MhlGO!F^=m@8@Ie88^<EsYD1;{veTT&+b|bRnb#@ 35 + z2Am8hM&Q?IXN2#P3~!urpHr8+JIMR`C~)B}()1`g{Nu|NSpJQdrZ#t5ZDCpLl)y5a 36 + zI3es-`NqV4QL_t{&2)4pvwsR>`%?=q2+>^o91FPtaKgX{mUR_AEr9NwT^_^@8>UHx 37 + z6Vql9ovz7$NpnPl5QlqsKP0|}EskXfTz-@ZpBlsI-~?B-4%w_GY9s9?ln=!A8wNMf 38 + z=;eAIwp6&0nZ9Qq({C*2AXx<ZqTxd)R(xsn?F4+P$}#JS<u|x9n19vknU^?{8C$(2 39 + z{+;MK!HljtP!Bu31=DpsC-~V`aJ>KEGCjGa)2K^@ryP2!6dWiRy0M0vL*145f@BzR 40 + z`IXs~3pGOq&;GIFDC<KN4u<vU;>=$JbDmK$C`A~nS$@*@hY$2e@kN0E;7jK@*TzW0 41 + z2FmwjqM0G-dfMDB3x5@pycb${TTJ7dzu+teFC9mUiSr(jrvQ~;sj`>uoen9{iXP5; 42 + zbF9`-K065Zx&Tv2gK4wf8(U++M31oGlTiV$(^)qux9<b>7HP~_mSNPtqZA#7xOo;Q 43 + zc-B?N(jYE1ix_L{M|cpCS4s5I3Tx_Hh1#!O-iBP5iS>iM{C~ZUez^pglqH5IAdOAD 44 + zE;AjwjL)_IsQU6y4t4Uc9KX<!`vlPxjFRGAG1tyHS7KLyRQx=Xb|!%gliE%^%h3dy 45 + zy_B7SK7#Pnxw+(BwLgXV=mq;Xy%bzqFkM7}Ja4VdI`gUr`jY+&xsnc80&0~P<68@c 46 + z{u<TCS!o65e}CwEOz7FcRp}Klf4OpWu~<6nq0Bfg&arHyU8C4!6fwm-i*mmh?)6iz 47 + zKf_(OO)Qy-luDqjA>l~!BYeA;B8*wK)|cg^tiD0aN2iWzR>YnUUS9?3jKhiAKpgQ0 48 + zCRNTKwkeO~{pU<lXv(vu7Dx<e`Gj7IBkT6lpsco*Mt@D}KlB*lL|<QAS}Q%n%XFvg 49 + zH*NegM<bJ@$syCdbs<RdsmXzbAZE0`((<O&d(r5^{{vn>2|S8)UYp21g#5$lGV|mN 50 + zdQNSkH%R6}>C_cemrZCjR|-)D<8Y^P74a}>FKs&95{8d9SiTvhB4{eTUGFbTa-IbX 51 + z8Lw1xoPPp_ZdIbl#dCzP1`N@V?eb7SizWtmm5ujG(rKZHZw$>Ozzf4E=Yf*+eH}kn 52 + zL{&n><${iESA&t~5pz;&vs%HlLYxXMjPlLH{?*-#T4#$(P;pke+8K*f)n*P<u~mu| 53 + zeR&;)e}Xp;P-smwSxfyE7_JA!0QRkDN~OJm4}W0Lw($YeyX)3OOY&fT$MrlC=TkO7 54 + zW{~(xl%jTs^n3F;k}Vc^eq7RCg8GuW-Aviu+r;$?G9+>h>SB9|8)|f;w=%OP6`nms 55 + zdFVyjJf#&^=o+P_Y1Q{$$Aql8VqX+>=4)lx*&wB#{$tlb&SwC3E~G-pKOFXRQrRSk 56 + zMt}Fa|4IQc;^N}stu@1BVjk>`8grvj#WVz9k#>7N?#JQZdag9deih!2aWVB}1klbo 57 + zXKhuKy*h7m?``T}aM*}PexaGXqeZCm>ME7BQi64D>9lL0AoF2`Tzo^hNSMC9B=jT@ 58 + zjA%viojl=gm6?1mPDg-)fy98Cb}Bdio_{dD+i;%nIJ6ZfQ^LPvl@~R#wk@K-##0ma 59 + zj|D(^Fr8U(o(FRy$Z0FGyp@<r=6Jk;ZS~i6PLo%BfGyU%%&PK=Cd{mo$^aBm71sz9 60 + z!b|Dl3+8A-e+{qm)PHbh4jn6it1^fhu{8?r62V15rUxqn)Y2|@zH!vqg?{4<et$(K 61 + zA?oQG3Vt}Nao4K3@C~cP)!cmK<q0(7iDfh<(I{qwIq$s=;ms)qU50x1eBN}%Cl9o- 62 + zwRUox>Yam&`H8oVv>6n8UKEVec>#*NVp)YrdOPgay4SxQ!PjT#gf^PISz?!W;(=9W 63 + zC3sT|H>d}ErTr;c8LY_P{IPZ;Wq&_a4mM`d<q|?_Y3|+P2w!A;3~GDKi<NZwt81V{ 64 + z#<XjJk{Nh~dU&Bc4nc;%e$m9B1SEbX2GL`Mh%4|dmPXqAo_SnHQ~ydAo2pWNJNr#r 65 + z&qMJ7Rm4cAa{Z?MBu-{<c!m#F`J8~VL}izx&JjpCwXp6YqQKH=rH`ccpMPM){}>Lu 66 + zn^@7N(YC~q-qV~usbu?>{mD#~LNHhWdnNcaB&J>J@M)=+i^{*SwmS-XE-ywoYoZ3U 67 + zdb4|M(!Ok$LY!K2m*Wjy>w_)zTm#k_g&J$V<PC_-!ZtfnOT~DwbQAMPmaiNnPYa}E 68 + zC0qUqV#JtW?*@6Xi2qh=#(y1ht<mz1hk2_-BW3zWZQOn&BF-DJNiVJ^UX?IbdyF-0 69 + zmYg7{z)#}hLcrrTmcVBJ-7O#q)Ue|c4_m)wviwN|OXYK1i@&98_P^=u3fJGVWKtPj 70 + zLs?WVkSk!O^@K;NKZL<c0zjFD)s9i7FY7DW#K^@KP^{b~G3k3kS$~nO@HF(x9_=3o 71 + z>Cb%~Wdvc^;lfge`vp1dg{O^Aqhg$$l5i=+C+Ser`<Wg)9QRGDYvyN4sSVl`6z?{B 72 + z2h<>KQQfEhZr}z4>;>#SGZZ*qGHd~H-az)BPnlSx{cgt);r6Mgt;@-+9-E2t@FXz( 73 + zkh$)!g3>JMRK2Fj;(t$e?z7fJ;>m@#_j;z{x_|Hzf&NnB2s)A$gs4~;$GCPhUTy3P 74 + z%V#3D9@E1QNOb5UL}Ny_3_nU3V+}u&a}K`l<S=I_q0bw!SVz^BZHUO(TE4MzZG&hv 75 + z)+F*X8}QBDV_CEe<6f<02^%m7*+MEwvjSRh#odK&cU%lpy?^`{7NcR^sU%E*+(Vq| 76 + zF~fBmxj2s}!*-mJ%K;2$0V^tq99=5EopA;?6BncK8H3Nu^jJ;;Kab!7;sYgKSZ@Gt 77 + zKv8=%d_ID#m7X)W2w?-i6t|7q0>A2AVv-@33!d)C9C2b%Dl#4Wp`G3Y#VXS2vODOK 78 + zH6Ik?Qx|Shj(@mOGi-V8X@6z3u=_{}wqW)r%+?8?joDa^!2`v;{CIT;J4sydrLxNW 79 + zgUBvqK&gHOJyAP#hP2e3p;XKF@8~4;2})IVed0tk&>;1X$v5<9B1$g>xX)iE=u<cm 80 + zo?`eC3Yy%Ey0VMh!y;fRqb!;mk=8A@RDT10v?wO&6Mu~_WsIoxKM+xi---tWuims! 81 + zj=~aAegI;IcBVmAc9q7(=cq;}OnHg8)o<i1`)eG>55Vhuq!Be_Zh7H_L0ojmJ-L7F 82 + zFE;pV4HH_WmO_~*TJ#EHu@A`M)iV-3k0!7^nIQ%08mJA-{<~wkIj0>_AP%Vz_4#<* 83 + vVkRz)@E~<X?BYZ$D8XaYxPKYw=O=*!-kqz(*(SYUmC&YW)6KUO5CQD(E<g{{ 84 + 85 + delta 4145 86 + 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 166 +
gitdiff/testdata/apply/bin_fragment_delta_modify_large.src

This is a binary file and will not be displayed.

gitdiff/testdata/apply/bin_fragment_literal_create.out

This is a binary file and will not be displayed.

+8
gitdiff/testdata/apply/bin_fragment_literal_create.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_literal_create.src b/gitdiff/testdata/apply/bin_fragment_literal_create.src 2 + GIT binary patch 3 + literal 32 4 + ocmZQzU`lR_IpTEv<rOIzfy0`|3>L*`JUiBtu5<US@-j_*0M$he#sB~S 5 + 6 + literal 0 7 + HcmV?d00001 8 +
gitdiff/testdata/apply/bin_fragment_literal_create.src

This is a binary file and will not be displayed.

gitdiff/testdata/apply/bin_fragment_literal_modify.out

This is a binary file and will not be displayed.

+8
gitdiff/testdata/apply/bin_fragment_literal_modify.patch
··· 1 + diff --git a/gitdiff/testdata/apply/bin_fragment_literal_modify.src b/gitdiff/testdata/apply/bin_fragment_literal_modify.src 2 + GIT binary patch 3 + literal 32 4 + ocmZQzU`lR_IpTEv<rOIzfy0_p?@r&O@$6Vny3XCR%F8tM0pJ`CjQ{`u 5 + 6 + literal 32 7 + ocmZQzU`lR_IpTEv<rOIzfy0`|3>L*`JUiBtu5<US@-j_*0M$he#sB~S 8 +
gitdiff/testdata/apply/bin_fragment_literal_modify.src

This is a binary file and will not be displayed.

gitdiff/testdata/apply/file_bin_modify.out

This is a binary file and will not be displayed.

+13
gitdiff/testdata/apply/file_bin_modify.patch
··· 1 + diff --git a/gitdiff/testdata/apply/file_bin_modify.src b/gitdiff/testdata/apply/file_bin_modify.src 2 + GIT binary patch 3 + delta 172 4 + zcmV;d08{^f2)qc8AP{I3VQ>J`s>wb0HU+h#6w8q?tUO~cHmDjZi2<8yZ9XmKhhMdo 5 + zWu(4bg|8QwzZ|1e*rL4P#)`Fen<n~ik=E?$qG6?hzJ6$u{l5W#?uwHb0q6w)00000 6 + zlLZ3%0RfW%1N%UMJ{~Z~0@X${&1Kk#98tb3==a{J7A;`O`v&<T@514_mvMTz72b#n 7 + atf$#NLoPbNe?RPFJVt1aCFGoQbiKD!OHgJ2 8 + 9 + delta 112 10 + zcmV-$0FVE?2!IHXAP~DY<7&llQfwqYA%tL<sR@xVtUMD;+4ZG>XTQ5=J2y;^BfB}4 11 + zWkisH791|vOVl5e-@^VLX0s~Ky_UyN!3;CgPr>Edj0j+0gOSwSsFsr$0q6zUJph<q 12 + SlLZ3%0XmZb1N#I__7UCuR5Dxu 13 +
gitdiff/testdata/apply/file_bin_modify.src

This is a binary file and will not be displayed.

+2
gitdiff/testdata/apply/file_mode_change.out
··· 1 + #!/bin/bash 2 + echo "this file is executable"
+3
gitdiff/testdata/apply/file_mode_change.patch
··· 1 + diff --git a/gitdiff/testdata/apply/file_mode_change.src b/gitdiff/testdata/apply/file_mode_change.src 2 + old mode 100644 3 + new mode 100755
+2
gitdiff/testdata/apply/file_mode_change.src
··· 1 + #!/bin/bash 2 + echo "this file is executable"
+200
gitdiff/testdata/apply/file_text.src
··· 1 + this is line 1 2 + this is line 2 3 + this is line 3 4 + this is line 4 5 + this is line 5 6 + this is line 6 7 + this is line 7 8 + this is line 8 9 + this is line 9 10 + this is line 10 11 + this is line 11 12 + this is line 12 13 + this is line 13 14 + this is line 14 15 + this is line 15 16 + this is line 16 17 + this is line 17 18 + this is line 18 19 + this is line 19 20 + this is line 20 21 + this is line 21 22 + this is line 22 23 + this is line 23 24 + this is line 24 25 + this is line 25 26 + this is line 26 27 + this is line 27 28 + this is line 28 29 + this is line 29 30 + this is line 30 31 + this is line 31 32 + this is line 32 33 + this is line 33 34 + this is line 34 35 + this is line 35 36 + this is line 36 37 + this is line 37 38 + this is line 38 39 + this is line 39 40 + this is line 40 41 + this is line 41 42 + this is line 42 43 + this is line 43 44 + this is line 44 45 + this is line 45 46 + this is line 46 47 + this is line 47 48 + this is line 48 49 + this is line 49 50 + this is line 50 51 + this is line 51 52 + this is line 52 53 + this is line 53 54 + this is line 54 55 + this is line 55 56 + this is line 56 57 + this is line 57 58 + this is line 58 59 + this is line 59 60 + this is line 60 61 + this is line 61 62 + this is line 62 63 + this is line 63 64 + this is line 64 65 + this is line 65 66 + this is line 66 67 + this is line 67 68 + this is line 68 69 + this is line 69 70 + this is line 70 71 + this is line 71 72 + this is line 72 73 + this is line 73 74 + this is line 74 75 + this is line 75 76 + this is line 76 77 + this is line 77 78 + this is line 78 79 + this is line 79 80 + this is line 80 81 + this is line 81 82 + this is line 82 83 + this is line 83 84 + this is line 84 85 + this is line 85 86 + this is line 86 87 + this is line 87 88 + this is line 88 89 + this is line 89 90 + this is line 90 91 + this is line 91 92 + this is line 92 93 + this is line 93 94 + this is line 94 95 + this is line 95 96 + this is line 96 97 + this is line 97 98 + this is line 98 99 + this is line 99 100 + this is line 100 101 + this is line 101 102 + this is line 102 103 + this is line 103 104 + this is line 104 105 + this is line 105 106 + this is line 106 107 + this is line 107 108 + this is line 108 109 + this is line 109 110 + this is line 110 111 + this is line 111 112 + this is line 112 113 + this is line 113 114 + this is line 114 115 + this is line 115 116 + this is line 116 117 + this is line 117 118 + this is line 118 119 + this is line 119 120 + this is line 120 121 + this is line 121 122 + this is line 122 123 + this is line 123 124 + this is line 124 125 + this is line 125 126 + this is line 126 127 + this is line 127 128 + this is line 128 129 + this is line 129 130 + this is line 130 131 + this is line 131 132 + this is line 132 133 + this is line 133 134 + this is line 134 135 + this is line 135 136 + this is line 136 137 + this is line 137 138 + this is line 138 139 + this is line 139 140 + this is line 140 141 + this is line 141 142 + this is line 142 143 + this is line 143 144 + this is line 144 145 + this is line 145 146 + this is line 146 147 + this is line 147 148 + this is line 148 149 + this is line 149 150 + this is line 150 151 + this is line 151 152 + this is line 152 153 + this is line 153 154 + this is line 154 155 + this is line 155 156 + this is line 156 157 + this is line 157 158 + this is line 158 159 + this is line 159 160 + this is line 160 161 + this is line 161 162 + this is line 162 163 + this is line 163 164 + this is line 164 165 + this is line 165 166 + this is line 166 167 + this is line 167 168 + this is line 168 169 + this is line 169 170 + this is line 170 171 + this is line 171 172 + this is line 172 173 + this is line 173 174 + this is line 174 175 + this is line 175 176 + this is line 176 177 + this is line 177 178 + this is line 178 179 + this is line 179 180 + this is line 180 181 + this is line 181 182 + this is line 182 183 + this is line 183 184 + this is line 184 185 + this is line 185 186 + this is line 186 187 + this is line 187 188 + this is line 188 189 + this is line 189 190 + this is line 190 191 + this is line 191 192 + this is line 192 193 + this is line 193 194 + this is line 194 195 + this is line 195 196 + this is line 196 197 + this is line 197 198 + this is line 198 199 + this is line 199 200 + this is line 200
gitdiff/testdata/apply/file_text_delete.out

This is a binary file and will not be displayed.

+206
gitdiff/testdata/apply/file_text_delete.patch
··· 1 + diff --git a/gitdiff/testdata/apply/file_text.src.src b/gitdiff/testdata/apply/file_text.src 2 + deleted file mode 100644 3 + index 3805ad4..0000000 4 + --- a/gitdiff/testdata/apply/file_text.src.src 5 + +++ /dev/null 6 + @@ -1,200 +0,0 @@ 7 + -this is line 1 8 + -this is line 2 9 + -this is line 3 10 + -this is line 4 11 + -this is line 5 12 + -this is line 6 13 + -this is line 7 14 + -this is line 8 15 + -this is line 9 16 + -this is line 10 17 + -this is line 11 18 + -this is line 12 19 + -this is line 13 20 + -this is line 14 21 + -this is line 15 22 + -this is line 16 23 + -this is line 17 24 + -this is line 18 25 + -this is line 19 26 + -this is line 20 27 + -this is line 21 28 + -this is line 22 29 + -this is line 23 30 + -this is line 24 31 + -this is line 25 32 + -this is line 26 33 + -this is line 27 34 + -this is line 28 35 + -this is line 29 36 + -this is line 30 37 + -this is line 31 38 + -this is line 32 39 + -this is line 33 40 + -this is line 34 41 + -this is line 35 42 + -this is line 36 43 + -this is line 37 44 + -this is line 38 45 + -this is line 39 46 + -this is line 40 47 + -this is line 41 48 + -this is line 42 49 + -this is line 43 50 + -this is line 44 51 + -this is line 45 52 + -this is line 46 53 + -this is line 47 54 + -this is line 48 55 + -this is line 49 56 + -this is line 50 57 + -this is line 51 58 + -this is line 52 59 + -this is line 53 60 + -this is line 54 61 + -this is line 55 62 + -this is line 56 63 + -this is line 57 64 + -this is line 58 65 + -this is line 59 66 + -this is line 60 67 + -this is line 61 68 + -this is line 62 69 + -this is line 63 70 + -this is line 64 71 + -this is line 65 72 + -this is line 66 73 + -this is line 67 74 + -this is line 68 75 + -this is line 69 76 + -this is line 70 77 + -this is line 71 78 + -this is line 72 79 + -this is line 73 80 + -this is line 74 81 + -this is line 75 82 + -this is line 76 83 + -this is line 77 84 + -this is line 78 85 + -this is line 79 86 + -this is line 80 87 + -this is line 81 88 + -this is line 82 89 + -this is line 83 90 + -this is line 84 91 + -this is line 85 92 + -this is line 86 93 + -this is line 87 94 + -this is line 88 95 + -this is line 89 96 + -this is line 90 97 + -this is line 91 98 + -this is line 92 99 + -this is line 93 100 + -this is line 94 101 + -this is line 95 102 + -this is line 96 103 + -this is line 97 104 + -this is line 98 105 + -this is line 99 106 + -this is line 100 107 + -this is line 101 108 + -this is line 102 109 + -this is line 103 110 + -this is line 104 111 + -this is line 105 112 + -this is line 106 113 + -this is line 107 114 + -this is line 108 115 + -this is line 109 116 + -this is line 110 117 + -this is line 111 118 + -this is line 112 119 + -this is line 113 120 + -this is line 114 121 + -this is line 115 122 + -this is line 116 123 + -this is line 117 124 + -this is line 118 125 + -this is line 119 126 + -this is line 120 127 + -this is line 121 128 + -this is line 122 129 + -this is line 123 130 + -this is line 124 131 + -this is line 125 132 + -this is line 126 133 + -this is line 127 134 + -this is line 128 135 + -this is line 129 136 + -this is line 130 137 + -this is line 131 138 + -this is line 132 139 + -this is line 133 140 + -this is line 134 141 + -this is line 135 142 + -this is line 136 143 + -this is line 137 144 + -this is line 138 145 + -this is line 139 146 + -this is line 140 147 + -this is line 141 148 + -this is line 142 149 + -this is line 143 150 + -this is line 144 151 + -this is line 145 152 + -this is line 146 153 + -this is line 147 154 + -this is line 148 155 + -this is line 149 156 + -this is line 150 157 + -this is line 151 158 + -this is line 152 159 + -this is line 153 160 + -this is line 154 161 + -this is line 155 162 + -this is line 156 163 + -this is line 157 164 + -this is line 158 165 + -this is line 159 166 + -this is line 160 167 + -this is line 161 168 + -this is line 162 169 + -this is line 163 170 + -this is line 164 171 + -this is line 165 172 + -this is line 166 173 + -this is line 167 174 + -this is line 168 175 + -this is line 169 176 + -this is line 170 177 + -this is line 171 178 + -this is line 172 179 + -this is line 173 180 + -this is line 174 181 + -this is line 175 182 + -this is line 176 183 + -this is line 177 184 + -this is line 178 185 + -this is line 179 186 + -this is line 180 187 + -this is line 181 188 + -this is line 182 189 + -this is line 183 190 + -this is line 184 191 + -this is line 185 192 + -this is line 186 193 + -this is line 187 194 + -this is line 188 195 + -this is line 189 196 + -this is line 190 197 + -this is line 191 198 + -this is line 192 199 + -this is line 193 200 + -this is line 194 201 + -this is line 195 202 + -this is line 196 203 + -this is line 197 204 + -this is line 198 205 + -this is line 199 206 + -this is line 200
+106
gitdiff/testdata/apply/file_text_error_partial_delete.patch
··· 1 + diff --git a/gitdiff/testdata/apply/file_text.src.src b/gitdiff/testdata/apply/file_text.src 2 + deleted file mode 100644 3 + index 3805ad4..0000000 4 + --- a/gitdiff/testdata/apply/file_text.src.src 5 + +++ /dev/null 6 + @@ -1,100 +0,0 @@ 7 + -this is line 1 8 + -this is line 2 9 + -this is line 3 10 + -this is line 4 11 + -this is line 5 12 + -this is line 6 13 + -this is line 7 14 + -this is line 8 15 + -this is line 9 16 + -this is line 10 17 + -this is line 11 18 + -this is line 12 19 + -this is line 13 20 + -this is line 14 21 + -this is line 15 22 + -this is line 16 23 + -this is line 17 24 + -this is line 18 25 + -this is line 19 26 + -this is line 20 27 + -this is line 21 28 + -this is line 22 29 + -this is line 23 30 + -this is line 24 31 + -this is line 25 32 + -this is line 26 33 + -this is line 27 34 + -this is line 28 35 + -this is line 29 36 + -this is line 30 37 + -this is line 31 38 + -this is line 32 39 + -this is line 33 40 + -this is line 34 41 + -this is line 35 42 + -this is line 36 43 + -this is line 37 44 + -this is line 38 45 + -this is line 39 46 + -this is line 40 47 + -this is line 41 48 + -this is line 42 49 + -this is line 43 50 + -this is line 44 51 + -this is line 45 52 + -this is line 46 53 + -this is line 47 54 + -this is line 48 55 + -this is line 49 56 + -this is line 50 57 + -this is line 51 58 + -this is line 52 59 + -this is line 53 60 + -this is line 54 61 + -this is line 55 62 + -this is line 56 63 + -this is line 57 64 + -this is line 58 65 + -this is line 59 66 + -this is line 60 67 + -this is line 61 68 + -this is line 62 69 + -this is line 63 70 + -this is line 64 71 + -this is line 65 72 + -this is line 66 73 + -this is line 67 74 + -this is line 68 75 + -this is line 69 76 + -this is line 70 77 + -this is line 71 78 + -this is line 72 79 + -this is line 73 80 + -this is line 74 81 + -this is line 75 82 + -this is line 76 83 + -this is line 77 84 + -this is line 78 85 + -this is line 79 86 + -this is line 80 87 + -this is line 81 88 + -this is line 82 89 + -this is line 83 90 + -this is line 84 91 + -this is line 85 92 + -this is line 86 93 + -this is line 87 94 + -this is line 88 95 + -this is line 89 96 + -this is line 90 97 + -this is line 91 98 + -this is line 92 99 + -this is line 93 100 + -this is line 94 101 + -this is line 95 102 + -this is line 96 103 + -this is line 97 104 + -this is line 98 105 + -this is line 99 106 + -this is line 100
+195
gitdiff/testdata/apply/file_text_modify.out
··· 1 + the first line is different 2 + this is line 2 3 + this is line 3 4 + this is line 4 5 + this is line 5 6 + this is line 6 7 + this is line 7 8 + this is line 8 9 + this is line 9 10 + this is line 10 11 + this is line 11 12 + this is line 12 13 + this is line 13 14 + this is line 14 15 + this is line 15 16 + this is line 16 17 + this is line 17 18 + this is line 18 19 + this is line 19 20 + this line offsets all the line numbers! 21 + this is line 20 22 + this is line 21 23 + until here, now we're back on track! 24 + this is line 24 25 + this is line 25 26 + this is line 26 27 + this is line 27 28 + this is line 28 29 + this is line 29 30 + this is line 30 31 + this is line 31 32 + this is line 32 33 + this is line 33 34 + this is line 34 35 + this is line 35 36 + this is line 36 37 + this is line 37 38 + this is line 38 39 + this is line 39 40 + this is line 40 41 + this is line 41 42 + this is line 42 43 + this is line 43 44 + this is line 44 45 + this is line 45 46 + this is line 46 47 + this is line 47 48 + this is line 48 49 + this is line 49 50 + this is line 50 51 + this is line 51 52 + this is line 52 53 + this is line 53 54 + this is line 54 55 + this is line 55 56 + once upon a time, a line 57 + in a text 58 + file 59 + changed 60 + this is line 60 61 + this is line 61 62 + this is line 62 63 + this is line 63 64 + this is line 64 65 + this is line 65 66 + this is line 66 67 + this is line 67 68 + this is line 68 69 + this is line 69 70 + this is line 70 71 + this is line 71 72 + this is line 72 73 + this is line 73 74 + this is line 74 75 + this is line 75 76 + this is line 76 77 + this is line 77 78 + this is line 78 79 + this is line 79 80 + this is line 80 81 + this is line 81 82 + this is line 82 83 + this is line 83 84 + this is line 84 85 + this is line 85 86 + this is line 86 87 + this is line 87 88 + this is line 88 89 + this is line 89 90 + this is line 90 91 + this is line 91 92 + this is line 92 93 + this is line 93 94 + this is line 94 95 + this is line 95 96 + this is line 96 97 + this is line 97 98 + this is line 98 99 + this is line 99 100 + this is line 100 101 + this is line 101 102 + this is line 102 103 + this is line 103 104 + this is line 104 105 + this is line 105 106 + this is line 106 107 + this is line 107 108 + this is line 108 109 + this is line 109 110 + this is line 110 111 + this is line 111 112 + this is line 112 113 + this is line 113 114 + this is line 114 115 + this is line 115 116 + this is line 116 117 + this is line 117 118 + this is line 118 119 + this is line 119 120 + this is line 120 121 + this is line 121 122 + this is line 122 123 + this is line 123 124 + this is line 124 125 + this is line 125 126 + this is line 126 127 + this is line 127 128 + this is line 128 129 + this is line 129 130 + this is line 130 131 + this is line 131 132 + this is line 132 133 + this line was bad and has been removed 134 + this line was REDACTED and has been REDACTED 135 + this is line 135 136 + this is line 136 137 + this is line 137 138 + this is line 138 139 + this is line 139 140 + this is line 140 141 + this is line 141 142 + this is line 142 143 + this is line 143 144 + this is line 144 145 + this is line 145 146 + this is line 146 147 + this is line 147 148 + this is line 148 149 + this is line 149 150 + this is line 150 151 + this is line 151 152 + this is line 152 153 + this is line 153 154 + this is line 154 155 + this is line 155 156 + this is line 156 157 + this is line 157 158 + this is line 158 159 + this is line 159 160 + this is line 160 161 + this is line 161 162 + this is line 162 163 + this is line 163 164 + the number on the remaining lines is 5 ahead of their actual position in the file 165 + this is line 170 166 + this is line 171 167 + this is line 172 168 + this is line 173 169 + this is line 174 170 + this is line 175 171 + this is line 176 172 + this is line 177 173 + this is line 178 174 + this is line 179 175 + this is line 180 176 + this is line 181 177 + this is line 182 178 + this is line 183 179 + this is line 184 180 + this is line 185 181 + this is line 186 182 + this is line 187 183 + this is line 188 184 + this is line 189 185 + this is line 190 186 + this is line 191 187 + this is line 192 188 + this is line 193 189 + this is line 194 190 + this is line 195 191 + this is line 196 192 + this is line 197 193 + this is line 198 194 + this is line 199 195 + this is line 200
+62
gitdiff/testdata/apply/file_text_modify.patch
··· 1 + diff --git a/gitdiff/testdata/apply/file_text.src b/gitdiff/testdata/apply/file_text.src 2 + --- a/gitdiff/testdata/apply/file_text.src 3 + +++ b/gitdiff/testdata/apply/file_text.src 4 + @@ -1,4 +1,4 @@ 5 + -this is line 1 6 + +the first line is different 7 + this is line 2 8 + this is line 3 9 + this is line 4 10 + @@ -17,10 +17,10 @@ this is line 16 11 + this is line 17 12 + this is line 18 13 + this is line 19 14 + +this line offsets all the line numbers! 15 + this is line 20 16 + this is line 21 17 + -this is line 22 18 + -this is line 23 19 + +until here, now we're back on track! 20 + this is line 24 21 + this is line 25 22 + this is line 26 23 + @@ -53,10 +53,10 @@ this is line 52 24 + this is line 53 25 + this is line 54 26 + this is line 55 27 + -this is line 56 28 + -this is line 57 29 + -this is line 58 30 + -this is line 59 31 + +once upon a time, a line 32 + + in a text 33 + + file 34 + + changed 35 + this is line 60 36 + this is line 61 37 + this is line 62 38 + @@ -130,8 +130,8 @@ this is line 129 39 + this is line 130 40 + this is line 131 41 + this is line 132 42 + -this is line 133 43 + -this is line 134 44 + +this line was bad and has been removed 45 + +this line was REDACTED and has been REDACTED 46 + this is line 135 47 + this is line 136 48 + this is line 137 49 + @@ -161,12 +161,7 @@ this is line 160 50 + this is line 161 51 + this is line 162 52 + this is line 163 53 + -this is line 164 54 + -this is line 165 55 + -this is line 166 56 + -this is line 167 57 + -this is line 168 58 + -this is line 169 59 + +the number on the remaining lines is 5 ahead of their actual position in the file 60 + this is line 170 61 + this is line 171 62 + this is line 172
+5
gitdiff/testdata/apply/text_fragment_add_end.out
··· 1 + line 1 2 + line 2 3 + line 3 4 + new line a 5 + new line b
+9
gitdiff/testdata/apply/text_fragment_add_end.patch
··· 1 + diff --git a/gitdiff/testdata/apply/fragment_add_end.src b/gitdiff/testdata/apply/fragment_add_end.src 2 + --- a/gitdiff/testdata/apply/fragment_add_end.src 3 + +++ b/gitdiff/testdata/apply/fragment_add_end.src 4 + @@ -1,3 +1,5 @@ 5 + line 1 6 + line 2 7 + line 3 8 + +new line a 9 + +new line b
+3
gitdiff/testdata/apply/text_fragment_add_end.src
··· 1 + line 1 2 + line 2 3 + line 3
+5
gitdiff/testdata/apply/text_fragment_add_end_noeol.out
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5
+11
gitdiff/testdata/apply/text_fragment_add_end_noeol.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_add_end_noeol.src b/gitdiff/testdata/apply/text_fragment_add_end_noeol.src 2 + --- a/gitdiff/testdata/apply/text_fragment_add_end_noeol.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_add_end_noeol.src 4 + @@ -1,3 +1,5 @@ 5 + line 1 6 + line 2 7 + -line 3 8 + \ No newline at end of file 9 + +line 3 10 + +line 4 11 + +line 5
+3
gitdiff/testdata/apply/text_fragment_add_end_noeol.src
··· 1 + line 1 2 + line 2 3 + line 3
+5
gitdiff/testdata/apply/text_fragment_add_middle.out
··· 1 + line 1 2 + line 2 3 + new line a 4 + new line b 5 + line 3
+9
gitdiff/testdata/apply/text_fragment_add_middle.patch
··· 1 + diff --git a/gitdiff/testdata/apply/fragment_add_middle.src b/gitdiff/testdata/apply/fragment_add_middle.src 2 + --- a/gitdiff/testdata/apply/fragment_add_middle.src 3 + +++ b/gitdiff/testdata/apply/fragment_add_middle.src 4 + @@ -1,3 +1,5 @@ 5 + line 1 6 + line 2 7 + +new line a 8 + +new line b 9 + line 3
+3
gitdiff/testdata/apply/text_fragment_add_middle.src
··· 1 + line 1 2 + line 2 3 + line 3
+4
gitdiff/testdata/apply/text_fragment_add_start.out
··· 1 + new line a 2 + line 1 3 + line 2 4 + line 3
+8
gitdiff/testdata/apply/text_fragment_add_start.patch
··· 1 + diff --git a/gitdiff/testdata/apply/fragment_add_start.src b/gitdiff/testdata/apply/fragment_add_start.src 2 + --- a/gitdiff/testdata/apply/fragment_add_start.src 3 + +++ b/gitdiff/testdata/apply/fragment_add_start.src 4 + @@ -1,3 +1,4 @@ 5 + +new line a 6 + line 1 7 + line 2 8 + line 3
+3
gitdiff/testdata/apply/text_fragment_add_start.src
··· 1 + line 1 2 + line 2 3 + line 3
+10
gitdiff/testdata/apply/text_fragment_change_end.out
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + line 6 7 + line 7 8 + line 8 9 + line 9 10 + new line a
+9
gitdiff/testdata/apply/text_fragment_change_end.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_change_end.src b/gitdiff/testdata/apply/text_fragment_change_end.src 2 + --- a/gitdiff/testdata/apply/text_fragment_change_end.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_change_end.src 4 + @@ -7,4 +7,4 @@ line 6 5 + line 7 6 + line 8 7 + line 9 8 + -line 10 9 + +new line a
+10
gitdiff/testdata/apply/text_fragment_change_end.src
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + line 6 7 + line 7 8 + line 8 9 + line 9 10 + line 10
+3
gitdiff/testdata/apply/text_fragment_change_end_eol.out
··· 1 + line 1 2 + line 2 3 + line 3
+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
+3
gitdiff/testdata/apply/text_fragment_change_end_eol.src
··· 1 + line 1 2 + line 2 3 + line 3
+19
gitdiff/testdata/apply/text_fragment_change_exact.out
··· 1 + line 2 + line 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 + line 11 + line 12 + line 13 + line 14 + line 15 + line 16 + new line a 17 + line 18 + line 19 + line
+12
gitdiff/testdata/apply/text_fragment_change_exact.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_change_exact.src b/gitdiff/testdata/apply/text_fragment_change_exact.src 2 + --- a/gitdiff/testdata/apply/text_fragment_change_exact.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_change_exact.src 4 + @@ -13,7 +13,7 @@ line 5 + line 6 + line 7 + line 8 + -line 9 + +new line a 10 + line 11 + line 12 + line
+30
gitdiff/testdata/apply/text_fragment_change_exact.src
··· 1 + line 2 + line 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 + line 11 + line 12 + line 13 + line 14 + line 15 + line 16 + line 17 + line 18 + line 19 + line 20 + line 21 + line 22 + line 23 + line 24 + line 25 + line 26 + line 27 + line 28 + line 29 + line 30 + line
+9
gitdiff/testdata/apply/text_fragment_change_middle.out
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + new line a 7 + line 7 8 + line 8 9 + line 9
+12
gitdiff/testdata/apply/text_fragment_change_middle.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_change_middle.src b/gitdiff/testdata/apply/text_fragment_change_middle.src 2 + --- a/gitdiff/testdata/apply/text_fragment_change_middle.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_change_middle.src 4 + @@ -3,7 +3,7 @@ line 2 5 + line 3 6 + line 4 7 + line 5 8 + -line 6 9 + +new line a 10 + line 7 11 + line 8 12 + line 9
+10
gitdiff/testdata/apply/text_fragment_change_middle.src
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + line 6 7 + line 7 8 + line 8 9 + line 9 10 + line 10
+1
gitdiff/testdata/apply/text_fragment_change_single_noeol.out
··· 1 + new line a
+8
gitdiff/testdata/apply/text_fragment_change_single_noeol.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_change_single_noeol.src b/gitdiff/testdata/apply/text_fragment_change_single_noeol.src 2 + --- a/gitdiff/testdata/apply/text_fragment_change_single_noeol.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_change_single_noeol.src 4 + @@ -1 +1 @@ 5 + -line 1 6 + \ No newline at end of file 7 + +new line a 8 + \ No newline at end of file
+1
gitdiff/testdata/apply/text_fragment_change_single_noeol.src
··· 1 + line 1
+4
gitdiff/testdata/apply/text_fragment_change_start.out
··· 1 + new line a 2 + line 2 3 + line 3 4 + line 4
+9
gitdiff/testdata/apply/text_fragment_change_start.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_change_start.src b/gitdiff/testdata/apply/text_fragment_change_start.src 2 + --- a/gitdiff/testdata/apply/text_fragment_change_start.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_change_start.src 4 + @@ -1,4 +1,4 @@ 5 + -line 1 6 + +new line a 7 + line 2 8 + line 3 9 + line 4
+10
gitdiff/testdata/apply/text_fragment_change_start.src
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + line 6 7 + line 7 8 + line 8 9 + line 9 10 + line 10
gitdiff/testdata/apply/text_fragment_delete_all.out

This is a binary file and will not be displayed.

+8
gitdiff/testdata/apply/text_fragment_delete_all.patch
··· 1 + diff --git a/gitdiff/testdata/apply/fragment_delete_all.src b/gitdiff/testdata/apply/fragment_delete_all.src 2 + --- a/gitdiff/testdata/apply/fragment_delete_all.src 3 + +++ b/gitdiff/testdata/apply/fragment_delete_all.src 4 + @@ -1,4 +0,0 @@ 5 + -line a 6 + -line b 7 + -line c 8 + -line d
+4
gitdiff/testdata/apply/text_fragment_delete_all.src
··· 1 + line a 2 + line b 3 + line c 4 + line d
+13
gitdiff/testdata/apply/text_fragment_error.src
··· 1 + line 1 2 + line 2 3 + line 3 4 + line 4 5 + line 5 6 + line 6 7 + line 7 8 + line 8 9 + line 9 10 + line 10 11 + line 11 12 + line 12 13 + line 13
+12
gitdiff/testdata/apply/text_fragment_error_context_conflict.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src 2 + --- a/gitdiff/testdata/apply/text_fragment_error.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_error.src 4 + @@ -4,7 +4,7 @@ line 3 5 + line 4 6 + line 5 7 + line conflict 8 + -line 7 9 + +new line a 10 + line 8 11 + line 9 12 + line 10
+12
gitdiff/testdata/apply/text_fragment_error_delete_conflict.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src 2 + --- a/gitdiff/testdata/apply/text_fragment_error.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_error.src 4 + @@ -4,7 +4,7 @@ line 3 5 + line 4 6 + line 5 7 + line 6 8 + -line conflict 9 + +new line a 10 + line 8 11 + line 9 12 + line 10
+7
gitdiff/testdata/apply/text_fragment_error_new_file.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src 2 + --- a/gitdiff/testdata/apply/text_fragment_error.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_error.src 4 + @@ -0,0 +1,3 @@ 5 + +line 1 6 + +line 2 7 + +line 3
+12
gitdiff/testdata/apply/text_fragment_error_short_src.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src 2 + --- a/gitdiff/testdata/apply/text_fragment_error.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_error.src 4 + @@ -9,7 +9,7 @@ line 8 5 + line 9 6 + line 10 7 + line 11 8 + -line 12 9 + +new line a 10 + line 13 11 + line 14 12 + line 15
+12
gitdiff/testdata/apply/text_fragment_error_short_src_before.patch
··· 1 + diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src 2 + --- a/gitdiff/testdata/apply/text_fragment_error.src 3 + +++ b/gitdiff/testdata/apply/text_fragment_error.src 4 + @@ -15,7 +15,7 @@ line 14 5 + line 15 6 + line 16 7 + line 17 8 + -line 18 9 + +new line a 10 + line 19 11 + line 20 12 + line 21
+3
gitdiff/testdata/apply/text_fragment_new.out
··· 1 + line 1 2 + line 2 3 + line 3
+7
gitdiff/testdata/apply/text_fragment_new.patch
··· 1 + diff --git a/gitdiff/testdata/apply/fragment_new.src b/gitdiff/testdata/apply/fragment_new.src 2 + --- a/gitdiff/testdata/apply/fragment_new.src 3 + +++ b/gitdiff/testdata/apply/fragment_new.src 4 + @@ -0,0 +1,3 @@ 5 + +line 1 6 + +line 2 7 + +line 3
gitdiff/testdata/apply/text_fragment_new.src

This is a binary file and will not be displayed.

+8
gitdiff/testdata/no_files.patch
··· 1 + commit 5d9790fec7d95aa223f3d20936340bf55ff3dcbe 2 + Author: Morton Haypenny <mhaypenny@example.com> 3 + Date: Tue Apr 2 22:55:40 2019 -0700 4 + 5 + A file with multiple fragments. 6 + 7 + The content is arbitrary. 8 +
+9
gitdiff/testdata/string/binary_modify.patch
··· 1 + diff --git a/file.bin b/file.bin 2 + index a7f4d5d6975ec021016c02b6d58345ebf434f38c..bdc9a70f055892146612dcdb413f0e339faaa0df 100644 3 + GIT binary patch 4 + delta 66 5 + QcmeZhVVvM$!$1K50C&Ox;s5{u 6 + 7 + delta 5 8 + McmZo+^qAlQ00i9urT_o{ 9 +
+3
gitdiff/testdata/string/binary_modify_nodata.patch
··· 1 + diff --git a/file.bin b/file.bin 2 + index a7f4d5d..bdc9a70 100644 3 + Binary files a/file.bin and b/file.bin differ
+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
··· 1 + diff --git a/file.txt b/numbers.txt 2 + similarity index 100% 3 + copy from file.txt 4 + copy to numbers.txt
+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
··· 1 + diff --git a/file.txt b/file.txt 2 + deleted file mode 100644 3 + index c9e9e05..0000000 4 + --- a/file.txt 5 + +++ /dev/null 6 + @@ -1,10 +0,0 @@ 7 + -one 8 + -two 9 + -three 10 + -four 11 + -five 12 + -six 13 + -seven 14 + -eight 15 + -nine 16 + -ten
+3
gitdiff/testdata/string/mode.patch
··· 1 + diff --git a/file.txt b/file.txt 2 + old mode 100644 3 + new mode 100755
+10
gitdiff/testdata/string/mode_modify.patch
··· 1 + diff --git a/script.sh b/script.sh 2 + old mode 100644 3 + new mode 100755 4 + index 7a870bd..68d501e 5 + --- a/script.sh 6 + +++ b/script.sh 7 + @@ -1,2 +1,2 @@ 8 + #!/bin/bash 9 + -echo "Hello World" 10 + +echo "Hello, World!"
+16
gitdiff/testdata/string/modify.patch
··· 1 + diff --git a/file.txt b/file.txt 2 + index c9e9e05..7d5fdc6 100644 3 + --- a/file.txt 4 + +++ b/file.txt 5 + @@ -3,8 +3,10 @@ two 6 + three 7 + four 8 + five 9 + -six 10 + +six six six six six six 11 + seven 12 + eight 13 + nine 14 + ten 15 + +eleven 16 + +twelve
+16
gitdiff/testdata/string/new.patch
··· 1 + diff --git a/file.txt b/file.txt 2 + new file mode 100644 3 + index 0000000..c9e9e05 4 + --- /dev/null 5 + +++ b/file.txt 6 + @@ -0,0 +1,10 @@ 7 + +one 8 + +two 9 + +three 10 + +four 11 + +five 12 + +six 13 + +seven 14 + +eight 15 + +nine 16 + +ten
+3
gitdiff/testdata/string/new_empty.patch
··· 1 + diff --git a/file.txt b/file.txt 2 + new file mode 100644 3 + index 0000000..e69de29
+16
gitdiff/testdata/string/new_mode.patch
··· 1 + diff --git a/file.sh b/file.sh 2 + new file mode 100755 3 + index 0000000..c9e9e05 4 + --- /dev/null 5 + +++ b/file.sh 6 + @@ -0,0 +1,10 @@ 7 + +one 8 + +two 9 + +three 10 + +four 11 + +five 12 + +six 13 + +seven 14 + +eight 15 + +nine 16 + +ten
+4
gitdiff/testdata/string/rename.patch
··· 1 + diff --git a/file.txt b/numbers.txt 2 + similarity index 100% 3 + rename from file.txt 4 + rename to numbers.txt
+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
+30 -18
gitdiff/text.go
··· 79 79 return p.Errorf(0, "no content following fragment header") 80 80 } 81 81 82 - isNoNewlineLine := func(s string) bool { 83 - // test for "\ No newline at end of file" by prefix because the text 84 - // changes by locale (git claims all versions are at least 12 chars) 85 - return len(s) >= 12 && s[:2] == "\\ " 86 - } 87 - 88 82 oldLines, newLines := frag.OldLines, frag.NewLines 89 - for { 83 + for oldLines > 0 || newLines > 0 { 90 84 line := p.Line(0) 91 85 op, data := line[0], line[1:] 92 86 ··· 113 107 frag.LinesAdded++ 114 108 frag.TrailingContext = 0 115 109 frag.Lines = append(frag.Lines, Line{OpAdd, data}) 116 - default: 110 + case '\\': 117 111 // this may appear in middle of fragment if it's for a deleted line 118 - if isNoNewlineLine(line) { 119 - last := &frag.Lines[len(frag.Lines)-1] 120 - last.Line = strings.TrimSuffix(last.Line, "\n") 112 + if isNoNewlineMarker(line) { 113 + removeLastNewline(frag) 121 114 break 122 115 } 116 + fallthrough 117 + default: 123 118 // TODO(bkeyes): if this is because we hit the next header, it 124 119 // would be helpful to return the miscounts line error. We could 125 120 // either test for the common headers ("@@ -", "diff --git") or 126 121 // assume any invalid op ends the fragment; git returns the same 127 122 // generic error in all cases so either is compatible 128 123 return p.Errorf(0, "invalid line operation: %q", op) 129 - } 130 - 131 - next := p.Line(1) 132 - if oldLines <= 0 && newLines <= 0 && !isNoNewlineLine(next) { 133 - break 134 124 } 135 125 136 126 if err := p.Next(); err != nil { ··· 145 135 hdr := max(frag.OldLines-oldLines, frag.NewLines-newLines) + 1 146 136 return p.Errorf(-hdr, "fragment header miscounts lines: %+d old, %+d new", -oldLines, -newLines) 147 137 } 138 + if frag.LinesAdded == 0 && frag.LinesDeleted == 0 { 139 + return p.Errorf(0, "fragment contains no changes") 140 + } 148 141 149 - if err := p.Next(); err != nil && err != io.EOF { 150 - return err 142 + // check for a final "no newline" marker since it is not included in the 143 + // counters used to stop the loop above 144 + if isNoNewlineMarker(p.Line(0)) { 145 + removeLastNewline(frag) 146 + if err := p.Next(); err != nil && err != io.EOF { 147 + return err 148 + } 151 149 } 150 + 152 151 return nil 152 + } 153 + 154 + func isNoNewlineMarker(s string) bool { 155 + // test for "\ No newline at end of file" by prefix because the text 156 + // changes by locale (git claims all versions are at least 12 chars) 157 + return len(s) >= 12 && s[:2] == "\\ " 158 + } 159 + 160 + func removeLastNewline(frag *TextFragment) { 161 + if len(frag.Lines) > 0 { 162 + last := &frag.Lines[len(frag.Lines)-1] 163 + last.Line = strings.TrimSuffix(last.Line, "\n") 164 + } 153 165 } 154 166 155 167 func parseRange(s string) (start int64, end int64, err error) {
+18
gitdiff/text_test.go
··· 317 317 }, 318 318 Err: true, 319 319 }, 320 + "onlyContext": { 321 + Input: ` context line 322 + context line 323 + `, 324 + Fragment: TextFragment{ 325 + OldLines: 2, 326 + NewLines: 2, 327 + }, 328 + Err: true, 329 + }, 330 + "unexpectedNoNewlineMarker": { 331 + Input: `\ No newline at end of file`, 332 + Fragment: TextFragment{ 333 + OldLines: 1, 334 + NewLines: 1, 335 + }, 336 + Err: true, 337 + }, 320 338 } 321 339 322 340 for name, test := range tests {
+1 -1
go.mod
··· 1 1 module github.com/bluekeyes/go-gitdiff 2 2 3 - go 1.12 3 + go 1.21