1package interdiff
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "strings"
9
10 "github.com/bluekeyes/go-gitdiff/gitdiff"
11)
12
13type ReconstructedLine struct {
14 LineNumber int64
15 Content string
16 IsUnknown bool
17}
18
19func NewLineAt(lineNumber int64, content string) ReconstructedLine {
20 return ReconstructedLine{
21 LineNumber: lineNumber,
22 Content: content,
23 IsUnknown: false,
24 }
25}
26
27type ReconstructedFile struct {
28 File string
29 Data []*ReconstructedLine
30}
31
32func (r *ReconstructedFile) String() string {
33 var i, j int64
34 var b strings.Builder
35 for {
36 i += 1
37
38 if int(j) >= (len(r.Data)) {
39 break
40 }
41
42 if r.Data[j].LineNumber == i {
43 // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
44 b.WriteString(r.Data[j].Content)
45 j += 1
46 } else {
47 //b.WriteString(fmt.Sprintf("%d:\n", i))
48 b.WriteString("\n")
49 }
50 }
51
52 return b.String()
53}
54
55func (r *ReconstructedFile) AddLine(line *ReconstructedLine) {
56 r.Data = append(r.Data, line)
57}
58
59func bestName(file *gitdiff.File) string {
60 if file.IsDelete {
61 return file.OldName
62 } else {
63 return file.NewName
64 }
65}
66
67// in-place reverse of a diff
68func reverseDiff(file *gitdiff.File) {
69 file.OldName, file.NewName = file.NewName, file.OldName
70 file.OldMode, file.NewMode = file.NewMode, file.OldMode
71 file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
72
73 for _, fragment := range file.TextFragments {
74 // swap postions
75 fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
76 fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
77 fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
78
79 for i := range fragment.Lines {
80 switch fragment.Lines[i].Op {
81 case gitdiff.OpAdd:
82 fragment.Lines[i].Op = gitdiff.OpDelete
83 case gitdiff.OpDelete:
84 fragment.Lines[i].Op = gitdiff.OpAdd
85 default:
86 // do nothing
87 }
88 }
89 }
90}
91
92// rebuild the original file from a patch
93func CreateOriginal(file *gitdiff.File) ReconstructedFile {
94 rf := ReconstructedFile{
95 File: bestName(file),
96 }
97
98 for _, fragment := range file.TextFragments {
99 position := fragment.OldPosition
100 for _, line := range fragment.Lines {
101 switch line.Op {
102 case gitdiff.OpContext:
103 rl := NewLineAt(position, line.Line)
104 rf.Data = append(rf.Data, &rl)
105 position += 1
106 case gitdiff.OpDelete:
107 rl := NewLineAt(position, line.Line)
108 rf.Data = append(rf.Data, &rl)
109 position += 1
110 case gitdiff.OpAdd:
111 // do nothing here
112 }
113 }
114 }
115
116 return rf
117}
118
119type MergeError struct {
120 msg string
121 mismatchingLine int64
122}
123
124func (m MergeError) Error() string {
125 return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
126}
127
128// best effort merging of two reconstructed files
129func (this *ReconstructedFile) Merge(other *ReconstructedFile) (*ReconstructedFile, error) {
130 mergedFile := ReconstructedFile{}
131
132 var i, j int64
133
134 for int(i) < len(this.Data) || int(j) < len(other.Data) {
135 if int(i) >= len(this.Data) {
136 // first file is done; the rest of the lines from file 2 can go in
137 mergedFile.AddLine(other.Data[j])
138 j++
139 continue
140 }
141
142 if int(j) >= len(other.Data) {
143 // first file is done; the rest of the lines from file 2 can go in
144 mergedFile.AddLine(this.Data[i])
145 i++
146 continue
147 }
148
149 line1 := this.Data[i]
150 line2 := other.Data[j]
151
152 if line1.LineNumber == line2.LineNumber {
153 if line1.Content != line2.Content {
154 return nil, MergeError{
155 msg: "mismatching lines, this patch might have undergone rebase",
156 mismatchingLine: line1.LineNumber,
157 }
158 } else {
159 mergedFile.AddLine(line1)
160 }
161 i++
162 j++
163 } else if line1.LineNumber < line2.LineNumber {
164 mergedFile.AddLine(line1)
165 i++
166 } else {
167 mergedFile.AddLine(line2)
168 j++
169 }
170 }
171
172 return &mergedFile, nil
173}
174
175func (r *ReconstructedFile) Apply(patch *gitdiff.File) (string, error) {
176 original := r.String()
177 var buffer bytes.Buffer
178 reader := strings.NewReader(original)
179
180 err := gitdiff.Apply(&buffer, reader, patch)
181 if err != nil {
182 return "", err
183 }
184
185 return buffer.String(), nil
186}
187
188func Unified(oldText, oldFile, newText, newFile string) (string, error) {
189 oldTemp, err := os.CreateTemp("", "old_*")
190 if err != nil {
191 return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
192 }
193 defer os.Remove(oldTemp.Name())
194 if _, err := oldTemp.WriteString(oldText); err != nil {
195 return "", fmt.Errorf("failed to write to old temp file: %w", err)
196 }
197 oldTemp.Close()
198
199 newTemp, err := os.CreateTemp("", "new_*")
200 if err != nil {
201 return "", fmt.Errorf("failed to create temp file for newText: %w", err)
202 }
203 defer os.Remove(newTemp.Name())
204 if _, err := newTemp.WriteString(newText); err != nil {
205 return "", fmt.Errorf("failed to write to new temp file: %w", err)
206 }
207 newTemp.Close()
208
209 cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
210 output, err := cmd.CombinedOutput()
211
212 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
213 return string(output), nil
214 }
215 if err != nil {
216 return "", fmt.Errorf("diff command failed: %w", err)
217 }
218
219 return string(output), nil
220}
221
222type InterdiffResult struct {
223 Files []*InterdiffFile
224}
225
226func (i *InterdiffResult) String() string {
227 var b strings.Builder
228 for _, f := range i.Files {
229 b.WriteString(f.String())
230 b.WriteString("\n")
231 }
232
233 return b.String()
234}
235
236type InterdiffFile struct {
237 *gitdiff.File
238 Name string
239 Status InterdiffFileStatus
240}
241
242func (s *InterdiffFile) String() string {
243 var b strings.Builder
244 b.WriteString(s.Status.String())
245 b.WriteString(" ")
246
247 if s.File != nil {
248 b.WriteString(bestName(s.File))
249 b.WriteString("\n")
250 b.WriteString(s.File.String())
251 }
252
253 return b.String()
254}
255
256type InterdiffFileStatus struct {
257 StatusKind StatusKind
258 Error error
259}
260
261func (s *InterdiffFileStatus) String() string {
262 kind := s.StatusKind.String()
263 if s.Error != nil {
264 return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
265 } else {
266 return kind
267 }
268}
269
270func (s *InterdiffFileStatus) IsOk() bool {
271 return s.StatusKind == StatusOk
272}
273
274func (s *InterdiffFileStatus) IsUnchanged() bool {
275 return s.StatusKind == StatusUnchanged
276}
277
278func (s *InterdiffFileStatus) IsOnlyInOne() bool {
279 return s.StatusKind == StatusOnlyInOne
280}
281
282func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
283 return s.StatusKind == StatusOnlyInTwo
284}
285
286func (s *InterdiffFileStatus) IsRebased() bool {
287 return s.StatusKind == StatusRebased
288}
289
290func (s *InterdiffFileStatus) IsError() bool {
291 return s.StatusKind == StatusError
292}
293
294type StatusKind int
295
296func (k StatusKind) String() string {
297 switch k {
298 case StatusOnlyInOne:
299 return "only in one"
300 case StatusOnlyInTwo:
301 return "only in two"
302 case StatusUnchanged:
303 return "unchanged"
304 case StatusRebased:
305 return "rebased"
306 case StatusError:
307 return "error"
308 default:
309 return "changed"
310 }
311}
312
313const (
314 StatusOk StatusKind = iota
315 StatusOnlyInOne
316 StatusOnlyInTwo
317 StatusUnchanged
318 StatusRebased
319 StatusError
320)
321
322func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
323 re1 := CreateOriginal(f1)
324 re2 := CreateOriginal(f2)
325
326 interdiffFile := InterdiffFile{
327 Name: bestName(f1),
328 }
329
330 merged, err := re1.Merge(&re2)
331 if err != nil {
332 interdiffFile.Status = InterdiffFileStatus{
333 StatusKind: StatusRebased,
334 Error: err,
335 }
336 return &interdiffFile
337 }
338
339 rev1, err := merged.Apply(f1)
340 if err != nil {
341 interdiffFile.Status = InterdiffFileStatus{
342 StatusKind: StatusError,
343 Error: err,
344 }
345 return &interdiffFile
346 }
347
348 rev2, err := merged.Apply(f2)
349 if err != nil {
350 interdiffFile.Status = InterdiffFileStatus{
351 StatusKind: StatusError,
352 Error: err,
353 }
354 return &interdiffFile
355 }
356
357 diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
358 if err != nil {
359 interdiffFile.Status = InterdiffFileStatus{
360 StatusKind: StatusError,
361 Error: err,
362 }
363 return &interdiffFile
364 }
365
366 parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
367 if err != nil {
368 interdiffFile.Status = InterdiffFileStatus{
369 StatusKind: StatusError,
370 Error: err,
371 }
372 return &interdiffFile
373 }
374
375 if len(parsed) != 1 {
376 // files are identical?
377 interdiffFile.Status = InterdiffFileStatus{
378 StatusKind: StatusUnchanged,
379 }
380 return &interdiffFile
381 }
382
383 if interdiffFile.Status.StatusKind == StatusOk {
384 interdiffFile.File = parsed[0]
385 }
386
387 return &interdiffFile
388}
389
390func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
391 fileToIdx1 := make(map[string]int)
392 fileToIdx2 := make(map[string]int)
393 visited := make(map[string]struct{})
394 var result InterdiffResult
395
396 for idx, f := range patch1 {
397 fileToIdx1[bestName(f)] = idx
398 }
399
400 for idx, f := range patch2 {
401 fileToIdx2[bestName(f)] = idx
402 }
403
404 for _, f1 := range patch1 {
405 var interdiffFile *InterdiffFile
406
407 fileName := bestName(f1)
408 if idx, ok := fileToIdx2[fileName]; ok {
409 f2 := patch2[idx]
410
411 // we have f1 and f2, calculate interdiff
412 interdiffFile = interdiffFiles(f1, f2)
413 } else {
414 // only in patch 1, this change would have to be "inverted" to dissapear
415 // from patch 2, so we reverseDiff(f1)
416 reverseDiff(f1)
417
418 interdiffFile = &InterdiffFile{
419 File: f1,
420 Name: fileName,
421 Status: InterdiffFileStatus{
422 StatusKind: StatusOnlyInOne,
423 },
424 }
425 }
426
427 result.Files = append(result.Files, interdiffFile)
428 visited[fileName] = struct{}{}
429 }
430
431 // for all files in patch2 that remain unvisited; we can just add them into the output
432 for _, f2 := range patch2 {
433 fileName := bestName(f2)
434 if _, ok := visited[fileName]; ok {
435 continue
436 }
437
438 result.Files = append(result.Files, &InterdiffFile{
439 File: f2,
440 Name: fileName,
441 Status: InterdiffFileStatus{
442 StatusKind: StatusOnlyInTwo,
443 },
444 })
445 }
446
447 return &result
448}