package git import ( "bytes" "fmt" "math" "strings" "sync" "github.com/aymanbagabas/git-module" "github.com/dustin/go-humanize/english" "github.com/sergi/go-diff/diffmatchpatch" ) // DiffSection is a wrapper to git.DiffSection with helper methods. type DiffSection struct { *git.DiffSection initOnce sync.Once dmp *diffmatchpatch.DiffMatchPatch } // diffFor computes inline diff for the given line. func (s *DiffSection) diffFor(line *git.DiffLine) string { fallback := line.Content // Find equivalent diff line, ignore when not found. var diff1, diff2 string switch line.Type { case git.DiffLineAdd: compareLine := s.Line(git.DiffLineDelete, line.RightLine) if compareLine == nil { return fallback } diff1 = compareLine.Content diff2 = line.Content case git.DiffLineDelete: compareLine := s.Line(git.DiffLineAdd, line.LeftLine) if compareLine == nil { return fallback } diff1 = line.Content diff2 = compareLine.Content default: return fallback } s.initOnce.Do(func() { s.dmp = diffmatchpatch.New() s.dmp.DiffEditCost = 100 }) diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true) diffs = s.dmp.DiffCleanupEfficiency(diffs) return diffsToString(diffs, line.Type) } func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string { buf := bytes.NewBuffer(nil) // Reproduce signs that were cut off for inline diff before. switch lineType { case git.DiffLineAdd: buf.WriteByte('+') case git.DiffLineDelete: buf.WriteByte('-') } for i := range diffs { switch { case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd: buf.WriteString(diffs[i].Text) case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete: buf.WriteString(diffs[i].Text) case diffs[i].Type == diffmatchpatch.DiffEqual: buf.WriteString(diffs[i].Text) } } return buf.String() } // DiffFile is a wrapper to git.DiffFile with helper methods. type DiffFile struct { *git.DiffFile Sections []*DiffSection } // DiffFileChange represents a file diff. type DiffFileChange struct { hash string name string mode git.EntryMode } // Hash returns the diff file hash. func (f *DiffFileChange) Hash() string { return f.hash } // Name returns the diff name. func (f *DiffFileChange) Name() string { return f.name } // Mode returns the diff file mode. func (f *DiffFileChange) Mode() git.EntryMode { return f.mode } // Files returns the diff files. func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) { if f.OldIndex != ZeroID { from = &DiffFileChange{ hash: f.OldIndex, name: f.OldName(), mode: f.OldMode(), } } if f.Index != ZeroID { to = &DiffFileChange{ hash: f.Index, name: f.Name, mode: f.Mode(), } } return } // FileStats type FileStats []*DiffFile // String returns a string representation of file stats. func (fs FileStats) String() string { return printStats(fs) } func printStats(stats FileStats) 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 stats { if int(longestLength) < len(fs.Name) { longestLength = float64(len(fs.Name)) } totalChange := fs.NumAdditions() + fs.NumDeletions() if int(longestTotalChange) < totalChange { longestTotalChange = float64(totalChange) } } // Parts of the output: // |<+++/---> // example: " main.go | 10 +++++++--- " // leftTextLength := padLength + longestLength + padLength // <+++++/-----> // 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 = longestTotalChange / heightOfHistogram } else { scaleFactor = 1.0 } taddc := 0 tdelc := 0 output := strings.Builder{} for _, fs := range stats { taddc += fs.NumAdditions() tdelc += fs.NumDeletions() addn := float64(fs.NumAdditions()) deln := float64(fs.NumDeletions()) addc := int(math.Floor(addn / scaleFactor)) delc := int(math.Floor(deln / scaleFactor)) if addc < 0 { addc = 0 } if delc < 0 { delc = 0 } adds := strings.Repeat("+", addc) dels := strings.Repeat("-", delc) diffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions()) totalDiffLines := fmt.Sprint(int(longestTotalChange)) fmt.Fprintf(&output, "%s | %s %s%s\n", fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)), strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines, adds, dels) } files := len(stats) fc := fmt.Sprintf("%s changed", english.Plural(files, "file", "")) ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", "")) dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", "")) fmt.Fprint(&output, fc) if taddc > 0 { fmt.Fprintf(&output, ", %s", ins) } if tdelc > 0 { fmt.Fprintf(&output, ", %s", dels) } fmt.Fprint(&output, "\n") return output.String() } // Diff is a wrapper around git.Diff with helper methods. type Diff struct { *git.Diff Files []*DiffFile } // FileStats returns the diff file stats. func (d *Diff) Stats() FileStats { return d.Files } const ( dstPrefix = "b/" srcPrefix = "a/" ) func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string { if isBinary { return append(lines, fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath), ) } return append(lines, fmt.Sprintf("--- %s", fromPath), fmt.Sprintf("+++ %s", toPath), ) } func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) { from, to := filePatch.Files() if from == nil && to == nil { return } isBinary := filePatch.IsBinary() var lines []string switch { case from != nil && to != nil: hashEquals := from.Hash() == to.Hash() lines = append(lines, fmt.Sprintf("diff --git %s%s %s%s", srcPrefix, from.Name(), dstPrefix, to.Name()), ) if from.Mode() != to.Mode() { lines = append(lines, fmt.Sprintf("old mode %o", from.Mode()), fmt.Sprintf("new mode %o", to.Mode()), ) } if from.Name() != to.Name() { lines = append(lines, fmt.Sprintf("rename from %s", from.Name()), fmt.Sprintf("rename to %s", to.Name()), ) } if from.Mode() != to.Mode() && !hashEquals { lines = append(lines, fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()), ) } else if !hashEquals { lines = append(lines, fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()), ) } if !hashEquals { lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary) } case from == nil: lines = append(lines, fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()), fmt.Sprintf("new file mode %o", to.Mode()), fmt.Sprintf("index %s..%s", ZeroID, to.Hash()), ) lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary) case to == nil: lines = append(lines, fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()), fmt.Sprintf("deleted file mode %o", from.Mode()), fmt.Sprintf("index %s..%s", from.Hash(), ZeroID), ) lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary) } sb.WriteString(lines[0]) for _, line := range lines[1:] { sb.WriteByte('\n') sb.WriteString(line) } sb.WriteByte('\n') } // Patch returns the diff as a patch. func (d *Diff) Patch() string { var p strings.Builder for _, f := range d.Files { writeFilePatchHeader(&p, f) for _, s := range f.Sections { for _, l := range s.Lines { p.WriteString(s.diffFor(l)) p.WriteString("\n") } } } return p.String() } func toDiff(ddiff *git.Diff) *Diff { files := make([]*DiffFile, 0, len(ddiff.Files)) for _, df := range ddiff.Files { sections := make([]*DiffSection, 0, len(df.Sections)) for _, ds := range df.Sections { sections = append(sections, &DiffSection{ DiffSection: ds, }) } files = append(files, &DiffFile{ DiffFile: df, Sections: sections, }) } diff := &Diff{ Diff: ddiff, Files: files, } return diff }