patch.go

  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}