diff options
-rw-r--r-- | COMPATIBILITY.md | 2 | ||||
-rw-r--r-- | plumbing/format/diff/patch.go | 58 | ||||
-rw-r--r-- | plumbing/format/diff/unified_encoder.go | 355 | ||||
-rw-r--r-- | plumbing/format/diff/unified_encoder_test.go | 829 | ||||
-rw-r--r-- | plumbing/object/change.go | 12 | ||||
-rw-r--r-- | plumbing/object/change_test.go | 25 | ||||
-rw-r--r-- | plumbing/object/commit.go | 15 | ||||
-rw-r--r-- | plumbing/object/commit_test.go | 54 | ||||
-rw-r--r-- | plumbing/object/file.go | 12 | ||||
-rw-r--r-- | plumbing/object/patch.go | 187 | ||||
-rw-r--r-- | plumbing/object/tree.go | 11 | ||||
-rw-r--r-- | utils/binary/read.go | 31 | ||||
-rw-r--r-- | utils/binary/read_test.go | 24 |
13 files changed, 1614 insertions, 1 deletions
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 1c17483..cc91433 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -39,7 +39,7 @@ is supported by go-git. | **patching** | | apply | ✖ | | cherry-pick | ✖ | -| diff | ✖ | +| diff | ✔ | Patch object with UnifiedDiff output representation | | rebase | ✖ | | revert | ✖ | | **debugging** | diff --git a/plumbing/format/diff/patch.go b/plumbing/format/diff/patch.go new file mode 100644 index 0000000..7c6cf4a --- /dev/null +++ b/plumbing/format/diff/patch.go @@ -0,0 +1,58 @@ +package diff + +import ( + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/filemode" +) + +// Operation defines the operation of a diff item. +type Operation int + +const ( + // Equal item represents a equals diff. + Equal Operation = iota + // Add item represents an insert diff. + Add + // Delete item represents a delete diff. + Delete +) + +// Patch represents a collection of steps to transform several files. +type Patch interface { + // FilePatches returns a slice of patches per file. + FilePatches() []FilePatch + // Message returns an optional message that can be at the top of the + // Patch representation. + Message() string +} + +// FilePatch represents the necessary steps to transform one file to another. +type FilePatch interface { + // IsBinary returns true if this patch is representing a binary file. + IsBinary() bool + // Files returns the from and to Files, with all the necessary metadata to + // about them. If the patch creates a new file, "from" will be nil. + // If the patch deletes a file, "to" will be nil. + Files() (from, to File) + // Chunks returns a slice of ordered changes to transform "from" File to + // "to" File. If the file is a binary one, Chunks will be empty. + Chunks() []Chunk +} + +// File contains all the file metadata necessary to print some patch formats. +type File interface { + // Hash returns the File Hash. + Hash() plumbing.Hash + // Mode returns the FileMode. + Mode() filemode.FileMode + // Path returns the complete Path to the file, including the filename. + Path() string +} + +// Chunk represents a portion of a file transformation to another. +type Chunk interface { + // Content contains the portion of the file. + Content() string + // Type contains the Operation to do with this Chunk. + Type() Operation +} diff --git a/plumbing/format/diff/unified_encoder.go b/plumbing/format/diff/unified_encoder.go new file mode 100644 index 0000000..a4ff7ab --- /dev/null +++ b/plumbing/format/diff/unified_encoder.go @@ -0,0 +1,355 @@ +package diff + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + + "gopkg.in/src-d/go-git.v4/plumbing" +) + +const ( + diffInit = "diff --git a/%s b/%s\n" + + chunkStart = "@@ -" + chunkMiddle = " +" + chunkEnd = " @@%s\n" + chunkCount = "%d,%d" + + noFilePath = "/dev/null" + aDir = "a/" + bDir = "b/" + + fPath = "--- %s\n" + tPath = "+++ %s\n" + binary = "Binary files %s and %s differ\n" + + addLine = "+%s\n" + deleteLine = "-%s\n" + equalLine = " %s\n" + + oldMode = "old mode %o\n" + newMode = "new mode %o\n" + deletedFileMode = "deleted file mode %o\n" + newFileMode = "new file mode %o\n" + + renameFrom = "from" + renameTo = "to" + renameFileMode = "rename %s %s\n" + + indexAndMode = "index %s..%s %o\n" + indexNoMode = "index %s..%s\n" + + DefaultContextLines = 3 +) + +var ErrBothFilesEmpty = errors.New("both files are empty") + +// UnifiedEncoder encodes an unified diff into the provided Writer. +// There are some unsupported features: +// - Similarity index for renames +// - Sort hash representation +type UnifiedEncoder struct { + io.Writer + + // ctxLines is the count of unchanged lines that will appear + // surrounding a change. + ctxLines int + + buf bytes.Buffer +} + +func NewUnifiedEncoder(w io.Writer, ctxLines int) *UnifiedEncoder { + return &UnifiedEncoder{ctxLines: ctxLines, Writer: w} +} + +func (e *UnifiedEncoder) Encode(patch Patch) error { + e.printMessage(patch.Message()) + + if err := e.encodeFilePatch(patch.FilePatches()); err != nil { + return err + } + + _, err := e.buf.WriteTo(e) + + return err +} + +func (e *UnifiedEncoder) encodeFilePatch(filePatches []FilePatch) error { + for _, p := range filePatches { + f, t := p.Files() + if err := e.header(f, t, p.IsBinary()); err != nil { + return err + } + + g := newHunksGenerator(p.Chunks(), e.ctxLines) + for _, c := range g.Generate() { + c.WriteTo(&e.buf) + } + } + + return nil +} + +func (e *UnifiedEncoder) printMessage(message string) { + isEmpty := message == "" + hasSuffix := strings.HasSuffix(message, "\n") + if !isEmpty && !hasSuffix { + message = message + "\n" + } + + e.buf.WriteString(message) +} + +func (e *UnifiedEncoder) header(from, to File, isBinary bool) error { + switch { + case from == nil && to == nil: + return ErrBothFilesEmpty + case from != nil && to != nil: + hashEquals := from.Hash() == to.Hash() + + fmt.Fprintf(&e.buf, diffInit, from.Path(), to.Path()) + + if from.Mode() != to.Mode() { + fmt.Fprintf(&e.buf, oldMode+newMode, from.Mode(), to.Mode()) + } + + if from.Path() != to.Path() { + fmt.Fprintf(&e.buf, + renameFileMode+renameFileMode, + renameFrom, from.Path(), renameTo, to.Path()) + } + + if from.Mode() != to.Mode() && !hashEquals { + fmt.Fprintf(&e.buf, indexNoMode, from.Hash(), to.Hash()) + } else if !hashEquals { + fmt.Fprintf(&e.buf, indexAndMode, from.Hash(), to.Hash(), from.Mode()) + } + + if !hashEquals { + e.pathLines(isBinary, aDir+from.Path(), bDir+to.Path()) + } + case from == nil: + fmt.Fprintf(&e.buf, diffInit, to.Path(), to.Path()) + fmt.Fprintf(&e.buf, newFileMode, to.Mode()) + fmt.Fprintf(&e.buf, indexNoMode, plumbing.ZeroHash, to.Hash()) + e.pathLines(isBinary, noFilePath, bDir+to.Path()) + case to == nil: + fmt.Fprintf(&e.buf, diffInit, from.Path(), from.Path()) + fmt.Fprintf(&e.buf, deletedFileMode, from.Mode()) + fmt.Fprintf(&e.buf, indexNoMode, from.Hash(), plumbing.ZeroHash) + e.pathLines(isBinary, aDir+from.Path(), noFilePath) + } + + return nil +} + +func (e *UnifiedEncoder) pathLines(isBinary bool, fromPath, toPath string) { + format := fPath + tPath + if isBinary { + format = binary + } + + fmt.Fprintf(&e.buf, format, fromPath, toPath) +} + +type hunksGenerator struct { + fromLine, toLine int + ctxLines int + chunks []Chunk + current *hunk + hunks []*hunk + beforeContext, afterContext []string +} + +func newHunksGenerator(chunks []Chunk, ctxLines int) *hunksGenerator { + return &hunksGenerator{ + chunks: chunks, + ctxLines: ctxLines, + } +} + +func (c *hunksGenerator) Generate() []*hunk { + for i, chunk := range c.chunks { + ls := splitLines(chunk.Content()) + lsLen := len(ls) + + switch chunk.Type() { + case Equal: + c.fromLine += lsLen + c.toLine += lsLen + c.processEqualsLines(ls, i) + case Delete: + if lsLen != 0 { + c.fromLine++ + } + + c.processHunk(i, chunk.Type()) + c.fromLine += lsLen - 1 + c.current.AddOp(chunk.Type(), ls...) + case Add: + if lsLen != 0 { + c.toLine++ + } + c.processHunk(i, chunk.Type()) + c.toLine += lsLen - 1 + c.current.AddOp(chunk.Type(), ls...) + } + + if i == len(c.chunks)-1 && c.current != nil { + c.hunks = append(c.hunks, c.current) + } + } + + return c.hunks +} + +func (c *hunksGenerator) processHunk(i int, op Operation) { + if c.current != nil { + return + } + + var ctxPrefix string + linesBefore := len(c.beforeContext) + if linesBefore > c.ctxLines { + ctxPrefix = " " + c.beforeContext[linesBefore-c.ctxLines-1] + c.beforeContext = c.beforeContext[linesBefore-c.ctxLines:] + linesBefore = c.ctxLines + } + + c.current = &hunk{ctxPrefix: ctxPrefix} + c.current.AddOp(Equal, c.beforeContext...) + + switch op { + case Delete: + c.current.fromLine, c.current.toLine = + c.addLineNumbers(c.fromLine, c.toLine, linesBefore, i, Add) + case Add: + c.current.toLine, c.current.fromLine = + c.addLineNumbers(c.toLine, c.fromLine, linesBefore, i, Delete) + } + + c.beforeContext = nil +} + +// addLineNumbers obtains the line numbers in a new chunk +func (c *hunksGenerator) addLineNumbers(la, lb int, linesBefore int, i int, op Operation) (cla, clb int) { + cla = la - linesBefore + // we need to search for a reference for the next diff + switch { + case linesBefore != 0 && c.ctxLines != 0: + clb = lb - c.ctxLines + 1 + case c.ctxLines == 0: + clb = lb - c.ctxLines + case i != len(c.chunks)-1: + next := c.chunks[i+1] + if next.Type() == op || next.Type() == Equal { + // this diff will be into this chunk + clb = lb + 1 + } + } + + return +} + +func (c *hunksGenerator) processEqualsLines(ls []string, i int) { + if c.current == nil { + c.beforeContext = append(c.beforeContext, ls...) + return + } + + c.afterContext = append(c.afterContext, ls...) + if len(c.afterContext) <= c.ctxLines*2 && i != len(c.chunks)-1 { + c.current.AddOp(Equal, c.afterContext...) + c.afterContext = nil + } else { + c.current.AddOp(Equal, c.afterContext[:c.ctxLines]...) + c.hunks = append(c.hunks, c.current) + + c.current = nil + c.beforeContext = c.afterContext[c.ctxLines:] + c.afterContext = nil + } +} + +func splitLines(s string) []string { + out := strings.Split(s, "\n") + if out[len(out)-1] == "" { + out = out[:len(out)-1] + } + + return out +} + +type hunk struct { + fromLine int + toLine int + + fromCount int + toCount int + + ctxPrefix string + ops []*op +} + +func (c *hunk) WriteTo(buf *bytes.Buffer) { + buf.WriteString(chunkStart) + + if c.fromCount == 1 { + fmt.Fprintf(buf, "%d", c.fromLine) + } else { + fmt.Fprintf(buf, chunkCount, c.fromLine, c.fromCount) + } + + buf.WriteString(chunkMiddle) + + if c.toCount == 1 { + fmt.Fprintf(buf, "%d", c.toLine) + } else { + fmt.Fprintf(buf, chunkCount, c.toLine, c.toCount) + } + + fmt.Fprintf(buf, chunkEnd, c.ctxPrefix) + + for _, d := range c.ops { + buf.WriteString(d.String()) + } +} + +func (c *hunk) AddOp(t Operation, s ...string) { + ls := len(s) + switch t { + case Add: + c.toCount += ls + case Delete: + c.fromCount += ls + case Equal: + c.toCount += ls + c.fromCount += ls + } + + for _, l := range s { + c.ops = append(c.ops, &op{l, t}) + } +} + +type op struct { + text string + t Operation +} + +func (o *op) String() string { + var prefix string + switch o.t { + case Add: + prefix = addLine + case Delete: + prefix = deleteLine + case Equal: + prefix = equalLine + } + + return fmt.Sprintf(prefix, o.text) +} diff --git a/plumbing/format/diff/unified_encoder_test.go b/plumbing/format/diff/unified_encoder_test.go new file mode 100644 index 0000000..b832920 --- /dev/null +++ b/plumbing/format/diff/unified_encoder_test.go @@ -0,0 +1,829 @@ +package diff + +import ( + "bytes" + "testing" + + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/filemode" + + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } + +type UnifiedEncoderTestSuite struct{} + +var _ = Suite(&UnifiedEncoderTestSuite{}) + +func (s *UnifiedEncoderTestSuite) TestBothFilesEmpty(c *C) { + buffer := bytes.NewBuffer(nil) + e := NewUnifiedEncoder(buffer, 1) + err := e.Encode(testPatch{filePatches: []testFilePatch{{}}}) + c.Assert(err, Equals, ErrBothFilesEmpty) +} + +func (s *UnifiedEncoderTestSuite) TestBinaryFile(c *C) { + buffer := bytes.NewBuffer(nil) + e := NewUnifiedEncoder(buffer, 1) + p := testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "binary", + seed: "something", + }, + to: &testFile{ + mode: filemode.Regular, + path: "binary", + seed: "otherthing", + }, + }}, + } + + err := e.Encode(p) + c.Assert(err, IsNil) + + c.Assert(buffer.String(), Equals, `diff --git a/binary b/binary +index a459bc245bdbc45e1bca99e7fe61731da5c48da4..6879395eacf3cc7e5634064ccb617ac7aa62be7d 100644 +Binary files a/binary and b/binary differ +`) +} + +func (s *UnifiedEncoderTestSuite) TestEncode(c *C) { + for _, f := range fixtures { + c.Log("executing: ", f.desc) + + buffer := bytes.NewBuffer(nil) + e := NewUnifiedEncoder(buffer, f.context) + + err := e.Encode(f.patch) + c.Assert(err, IsNil) + + c.Assert(buffer.String(), Equals, f.diff) + } +} + +var oneChunkPatch Patch = testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nÑ\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ", + }, + to: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "B\nC\nD\nE\nF\nG\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nV\nW\nX\nY\nZ", + }, + + chunks: []testChunk{{ + content: "A\n", + op: Delete, + }, { + content: "B\nC\nD\nE\nF\nG", + op: Equal, + }, { + content: "H\n", + op: Delete, + }, { + content: "I\nJ\nK\nL\nM\nN\n", + op: Equal, + }, { + content: "Ñ\n", + op: Delete, + }, { + content: "O\nP\nQ\nR\nS\nT\n", + op: Equal, + }, { + content: "U\n", + op: Delete, + }, { + content: "V\nW\nX\nY\nZ", + op: Equal, + }}, + }}, +} + +var oneChunkPatchInverted Patch = testPatch{ + message: "", + filePatches: []testFilePatch{{ + to: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nÑ\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ", + }, + from: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "B\nC\nD\nE\nF\nG\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nV\nW\nX\nY\nZ", + }, + + chunks: []testChunk{{ + content: "A\n", + op: Add, + }, { + content: "B\nC\nD\nE\nF\nG", + op: Equal, + }, { + content: "H\n", + op: Add, + }, { + content: "I\nJ\nK\nL\nM\nN\n", + op: Equal, + }, { + content: "Ñ\n", + op: Add, + }, { + content: "O\nP\nQ\nR\nS\nT\n", + op: Equal, + }, { + content: "U\n", + op: Add, + }, { + content: "V\nW\nX\nY\nZ", + op: Equal, + }}, + }}, +} + +var fixtures []*fixture = []*fixture{{ + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Executable, + path: "test.txt", + seed: "test", + }, + chunks: nil, + }}, + }, + desc: "make executable", + context: 1, + diff: `diff --git a/test.txt b/test.txt +old mode 100644 +new mode 100755 +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Regular, + path: "test1.txt", + seed: "test", + }, + chunks: nil, + }}, + }, + desc: "rename file", + context: 1, + diff: `diff --git a/test.txt b/test1.txt +rename from test.txt +rename to test1.txt +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Regular, + path: "test1.txt", + seed: "test1", + }, + chunks: []testChunk{{ + content: "test", + op: Delete, + }, { + content: "test1", + op: Add, + }}, + }}, + }, + desc: "rename file with changes", + context: 1, + diff: `diff --git a/test.txt b/test1.txt +rename from test.txt +rename to test1.txt +index 30d74d258442c7c65512eafab474568dd706c430..f079749c42ffdcc5f52ed2d3a6f15b09307e975e 100644 +--- a/test.txt ++++ b/test1.txt +@@ -1 +1 @@ +-test ++test1 +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Executable, + path: "test1.txt", + seed: "test", + }, + chunks: nil, + }}, + }, + desc: "rename with file mode change", + context: 1, + diff: `diff --git a/test.txt b/test1.txt +old mode 100644 +new mode 100755 +rename from test.txt +rename to test1.txt +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test2", + }, + + chunks: []testChunk{{ + content: "test", + op: Delete, + }, { + content: "test2", + op: Add, + }}, + }}, + }, + + desc: "one line change", + context: 1, + diff: `diff --git a/test.txt b/test.txt +index 30d74d258442c7c65512eafab474568dd706c430..d606037cb232bfda7788a8322492312d55b2ae9d 100644 +--- a/test.txt ++++ b/test.txt +@@ -1 +1 @@ +-test ++test2 +`, +}, { + patch: testPatch{ + message: "this is the message\n", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test2", + }, + + chunks: []testChunk{{ + content: "test", + op: Delete, + }, { + content: "test2", + op: Add, + }}, + }}, + }, + + desc: "one line change with message", + context: 1, + diff: `this is the message +diff --git a/test.txt b/test.txt +index 30d74d258442c7c65512eafab474568dd706c430..d606037cb232bfda7788a8322492312d55b2ae9d 100644 +--- a/test.txt ++++ b/test.txt +@@ -1 +1 @@ +-test ++test2 +`, +}, { + patch: testPatch{ + message: "this is the message", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test", + }, + to: &testFile{ + mode: filemode.Regular, + path: "test.txt", + seed: "test2", + }, + + chunks: []testChunk{{ + content: "test", + op: Delete, + }, { + content: "test2", + op: Add, + }}, + }}, + }, + + desc: "one line change with message and no end of line", + context: 1, + diff: `this is the message +diff --git a/test.txt b/test.txt +index 30d74d258442c7c65512eafab474568dd706c430..d606037cb232bfda7788a8322492312d55b2ae9d 100644 +--- a/test.txt ++++ b/test.txt +@@ -1 +1 @@ +-test ++test2 +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: nil, + to: &testFile{ + mode: filemode.Regular, + path: "new.txt", + seed: "test\ntest2\test3", + }, + + chunks: []testChunk{{ + content: "test\ntest2\ntest3", + op: Add, + }}, + }}, + }, + + desc: "new file", + context: 1, + diff: `diff --git a/new.txt b/new.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..65c8dd02a42273038658a22b1cb29c8d9457ca12 +--- /dev/null ++++ b/new.txt +@@ -0,0 +1,3 @@ ++test ++test2 ++test3 +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "old.txt", + seed: "test", + }, + to: nil, + + chunks: []testChunk{{ + content: "test", + op: Delete, + }}, + }}, + }, + + desc: "delete file", + context: 1, + diff: `diff --git a/old.txt b/old.txt +deleted file mode 100644 +index 30d74d258442c7c65512eafab474568dd706c430..0000000000000000000000000000000000000000 +--- a/old.txt ++++ /dev/null +@@ -1 +0,0 @@ +-test +`, +}, { + patch: oneChunkPatch, + desc: "modified deleting lines file with context to 1", + context: 1, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index ab5eed5d4a2c33aeef67e0188ee79bed666bde6f..0adddcde4fd38042c354518351820eb06c417c82 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,2 +1 @@ +-A + B +@@ -7,3 +6,2 @@ F + G +-H + I +@@ -14,3 +12,2 @@ M + N +-Ñ + O +@@ -21,3 +18,2 @@ S + T +-U + V +`, +}, { + patch: oneChunkPatch, + desc: "modified deleting lines file with context to 2", + context: 2, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index ab5eed5d4a2c33aeef67e0188ee79bed666bde6f..0adddcde4fd38042c354518351820eb06c417c82 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,3 +1,2 @@ +-A + B + C +@@ -6,5 +5,4 @@ E + F + G +-H + I + J +@@ -13,5 +11,4 @@ L + M + N +-Ñ + O + P +@@ -20,5 +17,4 @@ R + S + T +-U + V + W +`, +}, { + patch: oneChunkPatch, + + desc: "modified deleting lines file with context to 3", + context: 3, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index ab5eed5d4a2c33aeef67e0188ee79bed666bde6f..0adddcde4fd38042c354518351820eb06c417c82 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,25 +1,21 @@ +-A + B + C + D + E + F + G +-H + I + J + K + L + M + N +-Ñ + O + P + Q + R + S + T +-U + V + W + X +`, +}, { + patch: oneChunkPatch, + desc: "modified deleting lines file with context to 4", + context: 4, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index ab5eed5d4a2c33aeef67e0188ee79bed666bde6f..0adddcde4fd38042c354518351820eb06c417c82 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,26 +1,22 @@ +-A + B + C + D + E + F + G +-H + I + J + K + L + M + N +-Ñ + O + P + Q + R + S + T +-U + V + W + X + Y +`, +}, { + patch: oneChunkPatch, + desc: "modified deleting lines file with context to 0", + context: 0, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index ab5eed5d4a2c33aeef67e0188ee79bed666bde6f..0adddcde4fd38042c354518351820eb06c417c82 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1 +0,0 @@ +-A +@@ -8 +6,0 @@ G +-H +@@ -15 +12,0 @@ N +-Ñ +@@ -22 +18,0 @@ T +-U +`, +}, { + patch: oneChunkPatchInverted, + desc: "modified adding lines file with context to 1", + context: 1, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..ab5eed5d4a2c33aeef67e0188ee79bed666bde6f 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1 +1,2 @@ ++A + B +@@ -6,2 +7,3 @@ F + G ++H + I +@@ -12,2 +14,3 @@ M + N ++Ñ + O +@@ -18,2 +21,3 @@ S + T ++U + V +`, +}, { + patch: oneChunkPatchInverted, + desc: "modified adding lines file with context to 2", + context: 2, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..ab5eed5d4a2c33aeef67e0188ee79bed666bde6f 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,2 +1,3 @@ ++A + B + C +@@ -5,4 +6,5 @@ E + F + G ++H + I + J +@@ -11,4 +13,5 @@ L + M + N ++Ñ + O + P +@@ -17,4 +20,5 @@ R + S + T ++U + V + W +`, +}, { + patch: oneChunkPatchInverted, + desc: "modified adding lines file with context to 3", + context: 3, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..ab5eed5d4a2c33aeef67e0188ee79bed666bde6f 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,21 +1,25 @@ ++A + B + C + D + E + F + G ++H + I + J + K + L + M + N ++Ñ + O + P + Q + R + S + T ++U + V + W + X +`, +}, { + patch: oneChunkPatchInverted, + desc: "modified adding lines file with context to 4", + context: 4, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..ab5eed5d4a2c33aeef67e0188ee79bed666bde6f 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -1,22 +1,26 @@ ++A + B + C + D + E + F + G ++H + I + J + K + L + M + N ++Ñ + O + P + Q + R + S + T ++U + V + W + X + Y +`, +}, { + patch: oneChunkPatchInverted, + desc: "modified adding lines file with context to 0", + context: 0, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..ab5eed5d4a2c33aeef67e0188ee79bed666bde6f 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -0,0 +1 @@ ++A +@@ -6,0 +8 @@ G ++H +@@ -12,0 +15 @@ N ++Ñ +@@ -18,0 +22 @@ T ++U +`, +}, { + patch: testPatch{ + message: "", + filePatches: []testFilePatch{{ + from: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "B\nC\nD\nE\nF\nG\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nV\nW\nX\nY\nZ", + }, + to: &testFile{ + mode: filemode.Regular, + path: "onechunk.txt", + seed: "B\nC\nD\nE\nF\nG\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nV\nW\nX\nY\n", + }, + + chunks: []testChunk{{ + content: "B\nC\nD\nE\nF\nG\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nV\nW\nX\nY\n", + op: Equal, + }, { + content: "Z", + op: Delete, + }}, + }}, + }, + desc: "remove last letter", + context: 0, + diff: `diff --git a/onechunk.txt b/onechunk.txt +index 0adddcde4fd38042c354518351820eb06c417c82..553ae669c7a9303cf848fcc749a2569228ac5309 100644 +--- a/onechunk.txt ++++ b/onechunk.txt +@@ -23 +22,0 @@ Y +-Z +`, +}} + +type testPatch struct { + message string + filePatches []testFilePatch +} + +func (t testPatch) FilePatches() []FilePatch { + var result []FilePatch + for _, f := range t.filePatches { + result = append(result, f) + } + + return result +} + +func (t testPatch) Message() string { + return t.message +} + +type testFilePatch struct { + from, to *testFile + chunks []testChunk +} + +func (t testFilePatch) IsBinary() bool { + return len(t.chunks) == 0 +} +func (t testFilePatch) Files() (File, File) { + // Go is amazing + switch { + case t.from == nil && t.to == nil: + return nil, nil + case t.from == nil: + return nil, t.to + case t.to == nil: + return t.from, nil + } + + return t.from, t.to +} + +func (t testFilePatch) Chunks() []Chunk { + var result []Chunk + for _, c := range t.chunks { + result = append(result, c) + } + return result +} + +type testFile struct { + path string + mode filemode.FileMode + seed string +} + +func (t testFile) Hash() plumbing.Hash { + return plumbing.ComputeHash(plumbing.BlobObject, []byte(t.seed)) +} + +func (t testFile) Mode() filemode.FileMode { + return t.mode +} + +func (t testFile) Path() string { + return t.path +} + +type testChunk struct { + content string + op Operation +} + +func (t testChunk) Content() string { + return t.content +} + +func (t testChunk) Type() Operation { + return t.op +} + +type fixture struct { + desc string + context int + diff string + patch Patch +} diff --git a/plumbing/object/change.go b/plumbing/object/change.go index 2f702e4..729ff5a 100644 --- a/plumbing/object/change.go +++ b/plumbing/object/change.go @@ -78,6 +78,12 @@ func (c *Change) String() string { return fmt.Sprintf("<Action: %s, Path: %s>", action, c.name()) } +// Patch returns a Patch with all the file changes in chunks. This +// representation can be used to create several diff outputs. +func (c *Change) Patch() (*Patch, error) { + return getPatch("", c) +} + func (c *Change) name() string { if c.From != empty { return c.From.Name @@ -126,3 +132,9 @@ func (c Changes) String() string { return buffer.String() } + +// Patch returns a Patch with all the changes in chunks. This +// representation can be used to create several diff outputs. +func (c Changes) Patch() (*Patch, error) { + return getPatch("", c...) +} diff --git a/plumbing/object/change_test.go b/plumbing/object/change_test.go index bfd4613..ded7ff2 100644 --- a/plumbing/object/change_test.go +++ b/plumbing/object/change_test.go @@ -5,6 +5,7 @@ import ( "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/filemode" + "gopkg.in/src-d/go-git.v4/plumbing/format/diff" "gopkg.in/src-d/go-git.v4/plumbing/storer" "gopkg.in/src-d/go-git.v4/storage/filesystem" "gopkg.in/src-d/go-git.v4/utils/merkletrie" @@ -75,6 +76,12 @@ func (s *ChangeSuite) TestInsert(c *C) { c.Assert(to.Name, Equals, name) c.Assert(to.Blob.Hash, Equals, blob) + p, err := change.Patch() + c.Assert(err, IsNil) + c.Assert(len(p.FilePatches()), Equals, 1) + c.Assert(len(p.FilePatches()[0].Chunks()), Equals, 1) + c.Assert(p.FilePatches()[0].Chunks()[0].Type(), Equals, diff.Add) + str := change.String() c.Assert(str, Equals, "<Action: Insert, Path: examples/clone/main.go>") } @@ -121,6 +128,12 @@ func (s *ChangeSuite) TestDelete(c *C) { c.Assert(from.Name, Equals, name) c.Assert(from.Blob.Hash, Equals, blob) + p, err := change.Patch() + c.Assert(err, IsNil) + c.Assert(len(p.FilePatches()), Equals, 1) + c.Assert(len(p.FilePatches()[0].Chunks()), Equals, 1) + c.Assert(p.FilePatches()[0].Chunks()[0].Type(), Equals, diff.Delete) + str := change.String() c.Assert(str, Equals, "<Action: Delete, Path: utils/difftree/difftree.go>") } @@ -181,6 +194,18 @@ func (s *ChangeSuite) TestModify(c *C) { c.Assert(to.Name, Equals, name) c.Assert(to.Blob.Hash, Equals, toBlob) + p, err := change.Patch() + c.Assert(err, IsNil) + c.Assert(len(p.FilePatches()), Equals, 1) + c.Assert(len(p.FilePatches()[0].Chunks()), Equals, 7) + c.Assert(p.FilePatches()[0].Chunks()[0].Type(), Equals, diff.Equal) + c.Assert(p.FilePatches()[0].Chunks()[1].Type(), Equals, diff.Delete) + c.Assert(p.FilePatches()[0].Chunks()[2].Type(), Equals, diff.Add) + c.Assert(p.FilePatches()[0].Chunks()[3].Type(), Equals, diff.Equal) + c.Assert(p.FilePatches()[0].Chunks()[4].Type(), Equals, diff.Delete) + c.Assert(p.FilePatches()[0].Chunks()[5].Type(), Equals, diff.Add) + c.Assert(p.FilePatches()[0].Chunks()[6].Type(), Equals, diff.Equal) + str := change.String() c.Assert(str, Equals, "<Action: Modify, Path: utils/difftree/difftree.go>") } diff --git a/plumbing/object/commit.go b/plumbing/object/commit.go index 0a20ae6..c5a1867 100644 --- a/plumbing/object/commit.go +++ b/plumbing/object/commit.go @@ -64,6 +64,21 @@ func (c *Commit) Tree() (*Tree, error) { return GetTree(c.s, c.TreeHash) } +// Patch returns the Patch between the actual commit and the provided one. +func (c *Commit) Patch(to *Commit) (*Patch, error) { + fromTree, err := c.Tree() + if err != nil { + return nil, err + } + + toTree, err := to.Tree() + if err != nil { + return nil, err + } + + return fromTree.Patch(toTree) +} + // Parents return a CommitIter to the parent Commits. func (c *Commit) Parents() CommitIter { return NewCommitIter(c.s, diff --git a/plumbing/object/commit_test.go b/plumbing/object/commit_test.go index 87e80a5..e89302d 100644 --- a/plumbing/object/commit_test.go +++ b/plumbing/object/commit_test.go @@ -1,6 +1,7 @@ package object import ( + "bytes" "io" "strings" "time" @@ -66,6 +67,59 @@ func (s *SuiteCommit) TestParents(c *C) { i.Close() } +func (s *SuiteCommit) TestPatch(c *C) { + from := s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294")) + to := s.commit(c, plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5")) + + patch, err := from.Patch(to) + c.Assert(err, IsNil) + + buf := bytes.NewBuffer(nil) + err = patch.Encode(buf) + c.Assert(err, IsNil) + + c.Assert(buf.String(), Equals, `diff --git a/vendor/foo.go b/vendor/foo.go +new file mode 100644 +index 0000000000000000000000000000000000000000..9dea2395f5403188298c1dabe8bdafe562c491e3 +--- /dev/null ++++ b/vendor/foo.go +@@ -0,0 +1,7 @@ ++package main ++ ++import "fmt" ++ ++func main() { ++ fmt.Println("Hello, playground") ++} +`) + c.Assert(buf.String(), Equals, patch.String()) + + from = s.commit(c, plumbing.NewHash("b8e471f58bcbca63b07bda20e428190409c2db47")) + to = s.commit(c, plumbing.NewHash("35e85108805c84807bc66a02d91535e1e24b38b9")) + + patch, err = from.Patch(to) + c.Assert(err, IsNil) + + buf.Reset() + err = patch.Encode(buf) + c.Assert(err, IsNil) + + c.Assert(buf.String(), Equals, `diff --git a/CHANGELOG b/CHANGELOG +deleted file mode 100644 +index d3ff53e0564a9f87d8e84b6e28e5060e517008aa..0000000000000000000000000000000000000000 +--- a/CHANGELOG ++++ /dev/null +@@ -1 +0,0 @@ +-Initial changelog +diff --git a/binary.jpg b/binary.jpg +new file mode 100644 +index 0000000000000000000000000000000000000000..d5c0f4ab811897cadf03aec358ae60d21f91c50d +Binary files /dev/null and b/binary.jpg differ +`) + + c.Assert(buf.String(), Equals, patch.String()) +} + func (s *SuiteCommit) TestCommitEncodeDecodeIdempotent(c *C) { ts, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05-07:00") c.Assert(err, IsNil) diff --git a/plumbing/object/file.go b/plumbing/object/file.go index 6932c31..79f57fe 100644 --- a/plumbing/object/file.go +++ b/plumbing/object/file.go @@ -7,6 +7,7 @@ import ( "gopkg.in/src-d/go-git.v4/plumbing/filemode" "gopkg.in/src-d/go-git.v4/plumbing/storer" + "gopkg.in/src-d/go-git.v4/utils/binary" "gopkg.in/src-d/go-git.v4/utils/ioutil" ) @@ -42,6 +43,17 @@ func (f *File) Contents() (content string, err error) { return buf.String(), nil } +// IsBinary returns if the file is binary or not +func (f *File) IsBinary() (bool, error) { + reader, err := f.Reader() + if err != nil { + return false, err + } + defer ioutil.CheckClose(reader, &err) + + return binary.IsBinary(reader) +} + // Lines returns a slice of lines from the contents of a file, stripping // all end of line characters. If the last line is empty (does not end // in an end of line), it is also stripped. diff --git a/plumbing/object/patch.go b/plumbing/object/patch.go new file mode 100644 index 0000000..d413114 --- /dev/null +++ b/plumbing/object/patch.go @@ -0,0 +1,187 @@ +package object + +import ( + "bytes" + "fmt" + "io" + + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/filemode" + fdiff "gopkg.in/src-d/go-git.v4/plumbing/format/diff" + "gopkg.in/src-d/go-git.v4/utils/diff" + + dmp "github.com/sergi/go-diff/diffmatchpatch" +) + +func getPatch(message string, changes ...*Change) (*Patch, error) { + var filePatches []fdiff.FilePatch + for _, c := range changes { + fp, err := filePatch(c) + if err != nil { + return nil, err + } + + filePatches = append(filePatches, fp) + } + + return &Patch{message, filePatches}, nil +} + +func filePatch(c *Change) (fdiff.FilePatch, error) { + from, to, err := c.Files() + if err != nil { + return nil, err + } + fromContent, fIsBinary, err := fileContent(from) + if err != nil { + return nil, err + } + + toContent, tIsBinary, err := fileContent(to) + if err != nil { + return nil, err + } + + if fIsBinary || tIsBinary { + return &textFilePatch{from: c.From, to: c.To}, nil + } + + diffs := diff.Do(fromContent, toContent) + + var chunks []fdiff.Chunk + for _, d := range diffs { + var op fdiff.Operation + switch d.Type { + case dmp.DiffEqual: + op = fdiff.Equal + case dmp.DiffDelete: + op = fdiff.Delete + case dmp.DiffInsert: + op = fdiff.Add + } + + chunks = append(chunks, &textChunk{d.Text, op}) + } + + return &textFilePatch{ + chunks: chunks, + from: c.From, + to: c.To, + }, nil +} + +func fileContent(f *File) (content string, isBinary bool, err error) { + if f == nil { + return + } + + isBinary, err = f.IsBinary() + if err != nil || isBinary { + return + } + + content, err = f.Contents() + + return +} + +// textPatch is an implementation of fdiff.Patch interface +type Patch struct { + message string + filePatches []fdiff.FilePatch +} + +func (t *Patch) FilePatches() []fdiff.FilePatch { + return t.filePatches +} + +func (t *Patch) Message() string { + return t.message +} + +func (p *Patch) Encode(w io.Writer) error { + ue := fdiff.NewUnifiedEncoder(w, fdiff.DefaultContextLines) + + return ue.Encode(p) +} + +func (p *Patch) String() string { + buf := bytes.NewBuffer(nil) + err := p.Encode(buf) + if err != nil { + return fmt.Sprintf("malformed patch: %s", err.Error()) + } + + return buf.String() +} + +// changeEntryWrapper is an implementation of fdiff.File interface +type changeEntryWrapper struct { + ce ChangeEntry +} + +func (f *changeEntryWrapper) Hash() plumbing.Hash { + if !f.ce.TreeEntry.Mode.IsFile() { + return plumbing.ZeroHash + } + + return f.ce.TreeEntry.Hash +} + +func (f *changeEntryWrapper) Mode() filemode.FileMode { + return f.ce.TreeEntry.Mode +} +func (f *changeEntryWrapper) Path() string { + if !f.ce.TreeEntry.Mode.IsFile() { + return "" + } + + return f.ce.Name +} + +func (f *changeEntryWrapper) Empty() bool { + return !f.ce.TreeEntry.Mode.IsFile() +} + +// textFilePatch is an implementation of fdiff.FilePatch interface +type textFilePatch struct { + chunks []fdiff.Chunk + from, to ChangeEntry +} + +func (tf *textFilePatch) Files() (from fdiff.File, to fdiff.File) { + f := &changeEntryWrapper{tf.from} + t := &changeEntryWrapper{tf.to} + + if !f.Empty() { + from = f + } + + if !t.Empty() { + to = t + } + + return +} + +func (t *textFilePatch) IsBinary() bool { + return len(t.chunks) == 0 +} + +func (t *textFilePatch) Chunks() []fdiff.Chunk { + return t.chunks +} + +// textChunk is an implementation of fdiff.Chunk interface +type textChunk struct { + content string + op fdiff.Operation +} + +func (t *textChunk) Content() string { + return t.content +} + +func (t *textChunk) Type() fdiff.Operation { + return t.op +} diff --git a/plumbing/object/tree.go b/plumbing/object/tree.go index 25687b0..512db9f 100644 --- a/plumbing/object/tree.go +++ b/plumbing/object/tree.go @@ -270,6 +270,17 @@ func (from *Tree) Diff(to *Tree) (Changes, error) { return DiffTree(from, to) } +// Patch returns a slice of Patch objects with all the changes between trees +// in chunks. This representation can be used to create several diff outputs. +func (from *Tree) Patch(to *Tree) (*Patch, error) { + changes, err := DiffTree(from, to) + if err != nil { + return nil, err + } + + return changes.Patch() +} + // treeEntryIter facilitates iterating through the TreeEntry objects in a Tree. type treeEntryIter struct { t *Tree diff --git a/utils/binary/read.go b/utils/binary/read.go index 0bcf11a..c256ffe 100644 --- a/utils/binary/read.go +++ b/utils/binary/read.go @@ -3,6 +3,7 @@ package binary import ( + "bufio" "encoding/binary" "io" @@ -122,3 +123,33 @@ func ReadHash(r io.Reader) (plumbing.Hash, error) { return h, nil } + +const sniffLen = 8000 + +// IsBinary detects if data is a binary value based on: +// http://git.kernel.org/cgit/git/git.git/tree/xdiff-interface.c?id=HEAD#n198 +func IsBinary(r io.Reader) (bool, error) { + reader := bufio.NewReader(r) + c := 0 + for { + if c == sniffLen { + break + } + + b, err := reader.ReadByte() + if err == io.EOF { + break + } + if err != nil { + return false, err + } + + if b == byte(0) { + return true, nil + } + + c++ + } + + return false, nil +} diff --git a/utils/binary/read_test.go b/utils/binary/read_test.go index 59dbc30..5674653 100644 --- a/utils/binary/read_test.go +++ b/utils/binary/read_test.go @@ -85,3 +85,27 @@ func (s *BinarySuite) TestReadHash(c *C) { c.Assert(err, IsNil) c.Assert(hash.String(), Equals, expected.String()) } + +func (s *BinarySuite) TestIsBinary(c *C) { + buf := bytes.NewBuffer(nil) + buf.Write(bytes.Repeat([]byte{'A'}, sniffLen)) + buf.Write([]byte{0}) + ok, err := IsBinary(buf) + c.Assert(err, IsNil) + c.Assert(ok, Equals, false) + + buf.Reset() + + buf.Write(bytes.Repeat([]byte{'A'}, sniffLen-1)) + buf.Write([]byte{0}) + ok, err = IsBinary(buf) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + buf.Reset() + + buf.Write(bytes.Repeat([]byte{'A'}, 10)) + ok, err = IsBinary(buf) + c.Assert(err, IsNil) + c.Assert(ok, Equals, false) +} |