1package git
2
3import (
4 "bytes"
5 "fmt"
6 "math"
7 "strings"
8 "sync"
9
10 "github.com/dustin/go-humanize/english"
11 "github.com/gogs/git-module"
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 string(buf.Bytes())
86}
87
88// DiffFile is a wrapper to git.DiffFile with helper methods.
89type DiffFile struct {
90 *git.DiffFile
91 Sections []*DiffSection
92}
93
94type DiffFileChange struct {
95 hash string
96 name string
97 mode git.EntryMode
98}
99
100func (f *DiffFileChange) Hash() string {
101 return f.hash
102}
103
104func (f *DiffFileChange) Name() string {
105 return f.name
106}
107
108func (f *DiffFileChange) Mode() git.EntryMode {
109 return f.mode
110}
111
112func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
113 if f.OldIndex != ZeroHash.String() {
114 from = &DiffFileChange{
115 hash: f.OldIndex,
116 name: f.OldName(),
117 mode: f.OldMode(),
118 }
119 }
120 if f.Index != ZeroHash.String() {
121 to = &DiffFileChange{
122 hash: f.Index,
123 name: f.Name,
124 mode: f.Mode(),
125 }
126 }
127 return
128}
129
130// FileStats
131type FileStats []*DiffFile
132
133// String returns a string representation of file stats.
134func (fs FileStats) String() string {
135 return printStats(fs)
136}
137
138func printStats(stats FileStats) string {
139 padLength := float64(len(" "))
140 newlineLength := float64(len("\n"))
141 separatorLength := float64(len("|"))
142 // Soft line length limit. The text length calculation below excludes
143 // length of the change number. Adding that would take it closer to 80,
144 // but probably not more than 80, until it's a huge number.
145 lineLength := 72.0
146
147 // Get the longest filename and longest total change.
148 var longestLength float64
149 var longestTotalChange float64
150 for _, fs := range stats {
151 if int(longestLength) < len(fs.Name) {
152 longestLength = float64(len(fs.Name))
153 }
154 totalChange := fs.NumAdditions() + fs.NumDeletions()
155 if int(longestTotalChange) < totalChange {
156 longestTotalChange = float64(totalChange)
157 }
158 }
159
160 // Parts of the output:
161 // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
162 // example: " main.go | 10 +++++++--- "
163
164 // <pad><filename><pad>
165 leftTextLength := padLength + longestLength + padLength
166
167 // <pad><number><pad><+++++/-----><newline>
168 // Excluding number length here.
169 rightTextLength := padLength + padLength + newlineLength
170
171 totalTextArea := leftTextLength + separatorLength + rightTextLength
172 heightOfHistogram := lineLength - totalTextArea
173
174 // Scale the histogram.
175 var scaleFactor float64
176 if longestTotalChange > heightOfHistogram {
177 // Scale down to heightOfHistogram.
178 scaleFactor = longestTotalChange / heightOfHistogram
179 } else {
180 scaleFactor = 1.0
181 }
182
183 taddc := 0
184 tdelc := 0
185 output := strings.Builder{}
186 for _, fs := range stats {
187 taddc += fs.NumAdditions()
188 tdelc += fs.NumDeletions()
189 addn := float64(fs.NumAdditions())
190 deln := float64(fs.NumDeletions())
191 addc := int(math.Floor(addn / scaleFactor))
192 delc := int(math.Floor(deln / scaleFactor))
193 if addc < 0 {
194 addc = 0
195 }
196 if delc < 0 {
197 delc = 0
198 }
199 adds := strings.Repeat("+", addc)
200 dels := strings.Repeat("-", delc)
201 diffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions())
202 totalDiffLines := fmt.Sprint(int(longestTotalChange))
203 fmt.Fprintf(&output, "%s | %s %s%s\n",
204 fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
205 strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
206 adds,
207 dels)
208 }
209 files := len(stats)
210 fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
211 ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
212 dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
213 fmt.Fprint(&output, fc)
214 if taddc > 0 {
215 fmt.Fprintf(&output, ", %s", ins)
216 }
217 if tdelc > 0 {
218 fmt.Fprintf(&output, ", %s", dels)
219 }
220 fmt.Fprint(&output, "\n")
221
222 return output.String()
223}
224
225// Diff is a wrapper around git.Diff with helper methods.
226type Diff struct {
227 *git.Diff
228 Files []*DiffFile
229}
230
231// FileStats returns the diff file stats.
232func (d *Diff) Stats() FileStats {
233 return d.Files
234}
235
236const (
237 dstPrefix = "b/"
238 srcPrefix = "a/"
239)
240
241func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string {
242 if isBinary {
243 return append(lines,
244 fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath),
245 )
246 }
247 return append(lines,
248 fmt.Sprintf("--- %s", fromPath),
249 fmt.Sprintf("+++ %s", toPath),
250 )
251}
252
253func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
254 from, to := filePatch.Files()
255 if from == nil && to == nil {
256 return
257 }
258 isBinary := filePatch.IsBinary()
259
260 var lines []string
261 switch {
262 case from != nil && to != nil:
263 hashEquals := from.Hash() == to.Hash()
264 lines = append(lines,
265 fmt.Sprintf("diff --git %s%s %s%s",
266 srcPrefix, from.Name(), dstPrefix, to.Name()),
267 )
268 if from.Mode() != to.Mode() {
269 lines = append(lines,
270 fmt.Sprintf("old mode %o", from.Mode()),
271 fmt.Sprintf("new mode %o", to.Mode()),
272 )
273 }
274 if from.Name() != to.Name() {
275 lines = append(lines,
276 fmt.Sprintf("rename from %s", from.Name()),
277 fmt.Sprintf("rename to %s", to.Name()),
278 )
279 }
280 if from.Mode() != to.Mode() && !hashEquals {
281 lines = append(lines,
282 fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()),
283 )
284 } else if !hashEquals {
285 lines = append(lines,
286 fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()),
287 )
288 }
289 if !hashEquals {
290 lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary)
291 }
292 case from == nil:
293 lines = append(lines,
294 fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
295 fmt.Sprintf("new file mode %o", to.Mode()),
296 fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
297 )
298 lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
299 case to == nil:
300 lines = append(lines,
301 fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
302 fmt.Sprintf("deleted file mode %o", from.Mode()),
303 fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
304 )
305 lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
306 }
307
308 sb.WriteString(lines[0])
309 for _, line := range lines[1:] {
310 sb.WriteByte('\n')
311 sb.WriteString(line)
312 }
313 sb.WriteByte('\n')
314}
315
316// Patch returns the diff as a patch.
317func (d *Diff) Patch() string {
318 var p strings.Builder
319 for _, f := range d.Files {
320 writeFilePatchHeader(&p, f)
321 for _, s := range f.Sections {
322 for _, l := range s.Lines {
323 p.WriteString(s.diffFor(l))
324 p.WriteString("\n")
325 }
326 }
327 }
328 return p.String()
329}