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