|
|
|
package object
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"math"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"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"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
ErrCanceled = errors.New("operation canceled")
|
|
|
|
)
|
|
|
|
|
|
|
|
func getPatch(message string, changes ...*Change) (*Patch, error) {
|
|
|
|
ctx := context.Background()
|
|
|
|
return getPatchContext(ctx, message, changes...)
|
|
|
|
}
|
|
|
|
|
|
|
|
func getPatchContext(ctx context.Context, message string, changes ...*Change) (*Patch, error) {
|
|
|
|
var filePatches []fdiff.FilePatch
|
|
|
|
for _, c := range changes {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return nil, ErrCanceled
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
|
|
|
|
fp, err := filePatchWithContext(ctx, c)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
filePatches = append(filePatches, fp)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &Patch{message, filePatches}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func filePatchWithContext(ctx context.Context, 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 {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return nil, ErrCanceled
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
|
|
|
|
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 filePatch(c *Change) (fdiff.FilePatch, error) {
|
|
|
|
return filePatchWithContext(context.Background(), c)
|
|
|
|
}
|
|
|
|
|
|
|
|
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) Stats() FileStats {
|
|
|
|
return getFileStatsFromFilePatches(p.FilePatches())
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
// FileStat stores the status of changes in content of a file.
|
|
|
|
type FileStat struct {
|
|
|
|
Name string
|
|
|
|
Addition int
|
|
|
|
Deletion int
|
|
|
|
}
|
|
|
|
|
|
|
|
func (fs FileStat) String() string {
|
|
|
|
return printStat([]FileStat{fs})
|
|
|
|
}
|
|
|
|
|
|
|
|
// FileStats is a collection of FileStat.
|
|
|
|
type FileStats []FileStat
|
|
|
|
|
|
|
|
func (fileStats FileStats) String() string {
|
|
|
|
return printStat(fileStats)
|
|
|
|
}
|
|
|
|
|
|
|
|
func printStat(fileStats []FileStat) string {
|
|
|
|
padLength := float64(len(" "))
|
|
|
|
newlineLength := float64(len("\n"))
|
|
|
|
separatorLength := float64(len("|"))
|
|
|
|
// Soft line length limit. The text length calculation below excludes
|
|
|
|
// length of the change number. Adding that would take it closer to 80,
|
|
|
|
// but probably not more than 80, until it's a huge number.
|
|
|
|
lineLength := 72.0
|
|
|
|
|
|
|
|
// Get the longest filename and longest total change.
|
|
|
|
var longestLength float64
|
|
|
|
var longestTotalChange float64
|
|
|
|
for _, fs := range fileStats {
|
|
|
|
if int(longestLength) < len(fs.Name) {
|
|
|
|
longestLength = float64(len(fs.Name))
|
|
|
|
}
|
|
|
|
totalChange := fs.Addition + fs.Deletion
|
|
|
|
if int(longestTotalChange) < totalChange {
|
|
|
|
longestTotalChange = float64(totalChange)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parts of the output:
|
|
|
|
// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
|
|
|
|
// example: " main.go | 10 +++++++--- "
|
|
|
|
|
|
|
|
// <pad><filename><pad>
|
|
|
|
leftTextLength := padLength + longestLength + padLength
|
|
|
|
|
|
|
|
// <pad><number><pad><+++++/-----><newline>
|
|
|
|
// Excluding number length here.
|
|
|
|
rightTextLength := padLength + padLength + newlineLength
|
|
|
|
|
|
|
|
totalTextArea := leftTextLength + separatorLength + rightTextLength
|
|
|
|
heightOfHistogram := lineLength - totalTextArea
|
|
|
|
|
|
|
|
// Scale the histogram.
|
|
|
|
var scaleFactor float64
|
|
|
|
if longestTotalChange > heightOfHistogram {
|
|
|
|
// Scale down to heightOfHistogram.
|
|
|
|
scaleFactor = float64(longestTotalChange / heightOfHistogram)
|
|
|
|
} else {
|
|
|
|
scaleFactor = 1.0
|
|
|
|
}
|
|
|
|
|
|
|
|
finalOutput := ""
|
|
|
|
for _, fs := range fileStats {
|
|
|
|
addn := float64(fs.Addition)
|
|
|
|
deln := float64(fs.Deletion)
|
|
|
|
adds := strings.Repeat("+", int(math.Floor(addn/scaleFactor)))
|
|
|
|
dels := strings.Repeat("-", int(math.Floor(deln/scaleFactor)))
|
|
|
|
finalOutput += fmt.Sprintf(" %s | %d %s%s\n", fs.Name, (fs.Addition + fs.Deletion), adds, dels)
|
|
|
|
}
|
|
|
|
|
|
|
|
return finalOutput
|
|
|
|
}
|
|
|
|
|
|
|
|
func getFileStatsFromFilePatches(filePatches []fdiff.FilePatch) FileStats {
|
|
|
|
var fileStats FileStats
|
|
|
|
|
|
|
|
for _, fp := range filePatches {
|
|
|
|
// ignore empty patches (binary files, submodule refs updates)
|
|
|
|
if len(fp.Chunks()) == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
cs := FileStat{}
|
|
|
|
from, to := fp.Files()
|
|
|
|
if from == nil {
|
|
|
|
// New File is created.
|
|
|
|
cs.Name = to.Path()
|
|
|
|
} else if to == nil {
|
|
|
|
// File is deleted.
|
|
|
|
cs.Name = from.Path()
|
|
|
|
} else if from.Path() != to.Path() {
|
|
|
|
// File is renamed. Not supported.
|
|
|
|
// cs.Name = fmt.Sprintf("%s => %s", from.Path(), to.Path())
|
|
|
|
} else {
|
|
|
|
cs.Name = from.Path()
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, chunk := range fp.Chunks() {
|
|
|
|
s := chunk.Content()
|
|
|
|
if len(s) == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
switch chunk.Type() {
|
|
|
|
case fdiff.Add:
|
|
|
|
cs.Addition += strings.Count(s, "\n")
|
|
|
|
if s[len(s)-1] != '\n' {
|
|
|
|
cs.Addition++
|
|
|
|
}
|
|
|
|
case fdiff.Delete:
|
|
|
|
cs.Deletion += strings.Count(s, "\n")
|
|
|
|
if s[len(s)-1] != '\n' {
|
|
|
|
cs.Deletion++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fileStats = append(fileStats, cs)
|
|
|
|
}
|
|
|
|
|
|
|
|
return fileStats
|
|
|
|
}
|