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