1package git
2
3import (
4 "bytes"
5 "fmt"
6 "math"
7 "strings"
8 "sync"
9
10 git "github.com/aymanbagabas/git-module"
11 "github.com/dustin/go-humanize/english"
12 "github.com/sergi/go-diff/diffmatchpatch"
13)
14
15// DiffSection is a wrapper to git.DiffSection with helper methods.
16type DiffSection struct {
17 *git.DiffSection
18
19 initOnce sync.Once
20 dmp *diffmatchpatch.DiffMatchPatch
21}
22
23// diffFor computes inline diff for the given line.
24func (s *DiffSection) diffFor(line *git.DiffLine) string {
25 fallback := line.Content
26
27 // Find equivalent diff line, ignore when not found.
28 var diff1, diff2 string
29 switch line.Type {
30 case git.DiffLineAdd:
31 compareLine := s.DiffSection.Line(git.DiffLineDelete, line.RightLine)
32 if compareLine == nil {
33 return fallback
34 }
35
36 diff1 = compareLine.Content
37 diff2 = line.Content
38
39 case git.DiffLineDelete:
40 compareLine := s.DiffSection.Line(git.DiffLineAdd, line.LeftLine)
41 if compareLine == nil {
42 return fallback
43 }
44
45 diff1 = line.Content
46 diff2 = compareLine.Content
47
48 case git.DiffLinePlain, git.DiffLineSection:
49 return fallback
50 default:
51 return fallback
52 }
53
54 s.initOnce.Do(func() {
55 s.dmp = diffmatchpatch.New()
56 s.dmp.DiffEditCost = 100
57 })
58
59 diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true)
60 diffs = s.dmp.DiffCleanupEfficiency(diffs)
61
62 return diffsToString(diffs, line.Type)
63}
64
65func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string {
66 buf := bytes.NewBuffer(nil)
67
68 // Reproduce signs which are cutted for inline diff before.
69 switch lineType {
70 case git.DiffLineAdd:
71 buf.WriteByte('+')
72 case git.DiffLineDelete:
73 buf.WriteByte('-')
74 case git.DiffLinePlain, git.DiffLineSection:
75 // No prefix for these line types
76 }
77
78 for i := range diffs {
79 switch {
80 case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd:
81 buf.WriteString(diffs[i].Text)
82 case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete:
83 buf.WriteString(diffs[i].Text)
84 case diffs[i].Type == diffmatchpatch.DiffEqual:
85 buf.WriteString(diffs[i].Text)
86 }
87 }
88
89 return buf.String()
90}
91
92// DiffFile is a wrapper to git.DiffFile with helper methods.
93type DiffFile struct {
94 *git.DiffFile
95 Sections []*DiffSection
96}
97
98// DiffFileChange represents a file diff.
99type DiffFileChange struct {
100 hash string
101 name string
102 mode git.EntryMode
103}
104
105// Hash returns the diff file hash.
106func (f *DiffFileChange) Hash() string {
107 return f.hash
108}
109
110// Name returns the diff name.
111func (f *DiffFileChange) Name() string {
112 return f.name
113}
114
115// Mode returns the diff file mode.
116func (f *DiffFileChange) Mode() git.EntryMode {
117 return f.mode
118}
119
120// Files returns the diff files.
121func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
122 if f.DiffFile.OldIndex != git.EmptyID {
123 from = &DiffFileChange{
124 hash: f.DiffFile.OldIndex,
125 name: f.DiffFile.OldName(),
126 mode: f.DiffFile.OldMode(),
127 }
128 }
129 if f.DiffFile.Index != git.EmptyID {
130 to = &DiffFileChange{
131 hash: f.DiffFile.Index,
132 name: f.DiffFile.Name,
133 mode: f.DiffFile.Mode(),
134 }
135 }
136 return
137}
138
139// FileStats
140type FileStats []*DiffFile
141
142// String returns a string representation of file stats.
143func (fs FileStats) String() string {
144 return printStats(fs)
145}
146
147func printStats(stats FileStats) string {
148 padLength := float64(len(" "))
149 newlineLength := float64(len("\n"))
150 separatorLength := float64(len("|"))
151 // Soft line length limit. The text length calculation below excludes
152 // length of the change number. Adding that would take it closer to 80,
153 // but probably not more than 80, until it's a huge number.
154 lineLength := 72.0
155
156 // Get the longest filename and longest total change.
157 var longestLength float64
158 var longestTotalChange float64
159 for _, fs := range stats {
160 if int(longestLength) < len(fs.DiffFile.Name) {
161 longestLength = float64(len(fs.DiffFile.Name))
162 }
163 totalChange := fs.DiffFile.NumAdditions() + fs.DiffFile.NumDeletions()
164 if int(longestTotalChange) < totalChange {
165 longestTotalChange = float64(totalChange)
166 }
167 }
168
169 // Parts of the output:
170 // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
171 // example: " main.go | 10 +++++++--- "
172
173 // <pad><filename><pad>
174 leftTextLength := padLength + longestLength + padLength
175
176 // <pad><number><pad><+++++/-----><newline>
177 // Excluding number length here.
178 rightTextLength := padLength + padLength + newlineLength
179
180 totalTextArea := leftTextLength + separatorLength + rightTextLength
181 heightOfHistogram := lineLength - totalTextArea
182
183 // Scale the histogram.
184 var scaleFactor float64
185 if longestTotalChange > heightOfHistogram {
186 // Scale down to heightOfHistogram.
187 scaleFactor = longestTotalChange / heightOfHistogram
188 } else {
189 scaleFactor = 1.0
190 }
191
192 taddc := 0
193 tdelc := 0
194 output := strings.Builder{}
195 for _, fs := range stats {
196 taddc += fs.DiffFile.NumAdditions()
197 tdelc += fs.DiffFile.NumDeletions()
198 addn := float64(fs.DiffFile.NumAdditions())
199 deln := float64(fs.DiffFile.NumDeletions())
200 addc := int(math.Floor(addn / scaleFactor))
201 delc := int(math.Floor(deln / scaleFactor))
202 if addc < 0 {
203 addc = 0
204 }
205 if delc < 0 {
206 delc = 0
207 }
208 adds := strings.Repeat("+", addc)
209 dels := strings.Repeat("-", delc)
210 diffLines := fmt.Sprint(fs.DiffFile.NumAdditions() + fs.DiffFile.NumDeletions())
211 totalDiffLines := fmt.Sprint(int(longestTotalChange))
212 fmt.Fprintf(&output, "%s | %s %s%s\n",
213 fs.DiffFile.Name+strings.Repeat(" ", int(longestLength)-len(fs.DiffFile.Name)),
214 strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
215 adds,
216 dels)
217 }
218 files := len(stats)
219 fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
220 ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
221 dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
222 fmt.Fprint(&output, fc)
223 if taddc > 0 {
224 fmt.Fprintf(&output, ", %s", ins)
225 }
226 if tdelc > 0 {
227 fmt.Fprintf(&output, ", %s", dels)
228 }
229 fmt.Fprint(&output, "\n")
230
231 return output.String()
232}
233
234// Diff is a wrapper around git.Diff with helper methods.
235type Diff struct {
236 *git.Diff
237 Files []*DiffFile
238}
239
240// FileStats returns the diff file stats.
241func (d *Diff) Stats() FileStats {
242 return d.Files
243}
244
245const (
246 dstPrefix = "b/"
247 srcPrefix = "a/"
248)
249
250func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string {
251 if isBinary {
252 return append(lines,
253 fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath),
254 )
255 }
256 return append(lines,
257 fmt.Sprintf("--- %s", fromPath),
258 fmt.Sprintf("+++ %s", toPath),
259 )
260}
261
262func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
263 from, to := filePatch.Files()
264 if from == nil && to == nil {
265 return
266 }
267 isBinary := filePatch.DiffFile.IsBinary()
268
269 var lines []string
270 switch {
271 case from != nil && to != nil:
272 hashEquals := from.Hash() == to.Hash()
273 lines = append(lines,
274 fmt.Sprintf("diff --git %s%s %s%s",
275 srcPrefix, from.Name(), dstPrefix, to.Name()),
276 )
277 if from.Mode() != to.Mode() {
278 lines = append(lines,
279 fmt.Sprintf("old mode %o", from.Mode()),
280 fmt.Sprintf("new mode %o", to.Mode()),
281 )
282 }
283 if from.Name() != to.Name() {
284 lines = append(lines,
285 fmt.Sprintf("rename from %s", from.Name()),
286 fmt.Sprintf("rename to %s", to.Name()),
287 )
288 }
289 if from.Mode() != to.Mode() && !hashEquals {
290 lines = append(lines,
291 fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()),
292 )
293 } else if !hashEquals {
294 lines = append(lines,
295 fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()),
296 )
297 }
298 if !hashEquals {
299 lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary)
300 }
301 case from == nil:
302 lines = append(lines,
303 fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
304 fmt.Sprintf("new file mode %o", to.Mode()),
305 fmt.Sprintf("index %s..%s", git.EmptyID, to.Hash()),
306 )
307 lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
308 case to == nil:
309 lines = append(lines,
310 fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
311 fmt.Sprintf("deleted file mode %o", from.Mode()),
312 fmt.Sprintf("index %s..%s", from.Hash(), git.EmptyID),
313 )
314 lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
315 }
316
317 sb.WriteString(lines[0])
318 for _, line := range lines[1:] {
319 sb.WriteByte('\n')
320 sb.WriteString(line)
321 }
322 sb.WriteByte('\n')
323}
324
325// Patch returns the diff as a patch.
326func (d *Diff) Patch() string {
327 var p strings.Builder
328 for _, f := range d.Files {
329 writeFilePatchHeader(&p, f)
330 for _, s := range f.Sections {
331 for _, l := range s.DiffSection.Lines {
332 p.WriteString(s.diffFor(l))
333 p.WriteString("\n")
334 }
335 }
336 }
337 return p.String()
338}
339
340func toDiff(ddiff *git.Diff) *Diff {
341 files := make([]*DiffFile, 0, len(ddiff.Files))
342 for _, df := range ddiff.Files {
343 sections := make([]*DiffSection, 0, len(df.Sections))
344 for _, ds := range df.Sections {
345 sections = append(sections, &DiffSection{
346 DiffSection: ds,
347 })
348 }
349 files = append(files, &DiffFile{
350 DiffFile: df,
351 Sections: sections,
352 })
353 }
354 diff := &Diff{
355 Diff: ddiff,
356 Files: files,
357 }
358 return diff
359}