+27
-2
appview/db/pulls.go
+27
-2
appview/db/pulls.go
···
150
150
return false
151
151
}
152
152
153
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
153
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
154
154
patch := s.Patch
155
155
156
-
diffs, _, err := gitdiff.Parse(strings.NewReader(patch))
156
+
// if format-patch; then extract each patch
157
+
var diffs []*gitdiff.File
158
+
if patchutil.IsFormatPatch(patch) {
159
+
patches, err := patchutil.ExtractPatches(patch)
160
+
if err != nil {
161
+
return nil, err
162
+
}
163
+
var ps [][]*gitdiff.File
164
+
for _, p := range patches {
165
+
ps = append(ps, p.Files)
166
+
}
167
+
168
+
diffs = patchutil.CombineDiff(ps...)
169
+
} else {
170
+
d, _, err := gitdiff.Parse(strings.NewReader(patch))
171
+
if err != nil {
172
+
return nil, err
173
+
}
174
+
diffs = d
175
+
}
176
+
177
+
return diffs, nil
178
+
}
179
+
180
+
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
181
+
diffs, err := s.AsDiff(targetBranch)
157
182
if err != nil {
158
183
log.Println(err)
159
184
}
+2
-2
appview/pages/pages.go
+2
-2
appview/pages/pages.go
···
20
20
"tangled.sh/tangled.sh/core/appview/db"
21
21
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
22
"tangled.sh/tangled.sh/core/appview/state/userutil"
23
-
"tangled.sh/tangled.sh/core/interdiff"
23
+
"tangled.sh/tangled.sh/core/patchutil"
24
24
"tangled.sh/tangled.sh/core/types"
25
25
26
26
"github.com/alecthomas/chroma/v2"
···
715
715
RepoInfo RepoInfo
716
716
Pull *db.Pull
717
717
Round int
718
-
Interdiff *interdiff.InterdiffResult
718
+
Interdiff *patchutil.InterdiffResult
719
719
}
720
720
721
721
// this name is a mouthful
+7
-10
appview/pages/templates/repo/pulls/pull.html
+7
-10
appview/pages/templates/repo/pulls/pull.html
···
51
51
</span>
52
52
</div>
53
53
54
-
{{ if $.Pull.IsPatchBased }}
55
-
<!-- view patch -->
56
54
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
57
55
hx-boost="true"
58
56
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
59
57
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
60
58
</a>
61
-
{{ if not (eq .RoundNumber 0) }}
62
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
63
-
hx-boost="true"
64
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
65
-
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
66
-
</a>
67
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
68
-
{{ end }}
59
+
{{ if not (eq .RoundNumber 0) }}
60
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
61
+
hx-boost="true"
62
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
63
+
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
64
+
</a>
65
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
69
66
{{ end }}
70
67
</div>
71
68
</summary>
+3
-6
appview/state/pull.go
+3
-6
appview/state/pull.go
···
10
10
"net/http"
11
11
"net/url"
12
12
"strconv"
13
-
"strings"
14
13
"time"
15
14
16
15
"tangled.sh/tangled.sh/core/api/tangled"
17
16
"tangled.sh/tangled.sh/core/appview/auth"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
18
"tangled.sh/tangled.sh/core/appview/pages"
20
-
"tangled.sh/tangled.sh/core/interdiff"
21
19
"tangled.sh/tangled.sh/core/patchutil"
22
20
"tangled.sh/tangled.sh/core/types"
23
21
24
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
25
22
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
23
"github.com/bluesky-social/indigo/atproto/syntax"
27
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
351
348
}
352
349
}
353
350
354
-
currentPatch, _, err := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt].Patch))
351
+
currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
355
352
if err != nil {
356
353
log.Println("failed to interdiff; current patch malformed")
357
354
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
358
355
return
359
356
}
360
357
361
-
previousPatch, _, err := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt-1].Patch))
358
+
previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
362
359
if err != nil {
363
360
log.Println("failed to interdiff; previous patch malformed")
364
361
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
365
362
return
366
363
}
367
364
368
-
interdiff := interdiff.Interdiff(previousPatch, currentPatch)
365
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
369
366
370
367
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
371
368
LoggedInUser: s.auth.GetUser(r),
+38
cmd/combinediff/main.go
+38
cmd/combinediff/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
+
)
10
+
11
+
func main() {
12
+
if len(os.Args) != 3 {
13
+
fmt.Println("Usage: combinediff <patch1> <patch2>")
14
+
os.Exit(1)
15
+
}
16
+
17
+
patch1, err := os.Open(os.Args[1])
18
+
if err != nil {
19
+
fmt.Println(err)
20
+
}
21
+
patch2, err := os.Open(os.Args[2])
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files1, _, err := gitdiff.Parse(patch1)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
files2, _, err := gitdiff.Parse(patch2)
32
+
if err != nil {
33
+
fmt.Println(err)
34
+
}
35
+
36
+
combined := patchutil.CombineDiff(files1, files2)
37
+
fmt.Println(combined)
38
+
}
+2
-2
cmd/interdiff/main.go
+2
-2
cmd/interdiff/main.go
···
5
5
"os"
6
6
7
7
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
-
"tangled.sh/tangled.sh/core/interdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
9
)
10
10
11
11
func main() {
···
33
33
fmt.Println(err)
34
34
}
35
35
36
-
interDiffResult := interdiff.Interdiff(files1, files2)
36
+
interDiffResult := patchutil.Interdiff(files1, files2)
37
37
fmt.Println(interDiffResult)
38
38
}
-448
interdiff/interdiff.go
-448
interdiff/interdiff.go
···
1
-
package interdiff
2
-
3
-
import (
4
-
"bytes"
5
-
"fmt"
6
-
"os"
7
-
"os/exec"
8
-
"strings"
9
-
10
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
11
-
)
12
-
13
-
type ReconstructedLine struct {
14
-
LineNumber int64
15
-
Content string
16
-
IsUnknown bool
17
-
}
18
-
19
-
func NewLineAt(lineNumber int64, content string) ReconstructedLine {
20
-
return ReconstructedLine{
21
-
LineNumber: lineNumber,
22
-
Content: content,
23
-
IsUnknown: false,
24
-
}
25
-
}
26
-
27
-
type ReconstructedFile struct {
28
-
File string
29
-
Data []*ReconstructedLine
30
-
}
31
-
32
-
func (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
-
55
-
func (r *ReconstructedFile) AddLine(line *ReconstructedLine) {
56
-
r.Data = append(r.Data, line)
57
-
}
58
-
59
-
func 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
68
-
func 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
93
-
func 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
-
119
-
type MergeError struct {
120
-
msg string
121
-
mismatchingLine int64
122
-
}
123
-
124
-
func (m MergeError) Error() string {
125
-
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
126
-
}
127
-
128
-
// best effort merging of two reconstructed files
129
-
func (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
-
175
-
func (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
-
188
-
func 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
-
222
-
type InterdiffResult struct {
223
-
Files []*InterdiffFile
224
-
}
225
-
226
-
func (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
-
236
-
type InterdiffFile struct {
237
-
*gitdiff.File
238
-
Name string
239
-
Status InterdiffFileStatus
240
-
}
241
-
242
-
func (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
-
256
-
type InterdiffFileStatus struct {
257
-
StatusKind StatusKind
258
-
Error error
259
-
}
260
-
261
-
func (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
-
270
-
func (s *InterdiffFileStatus) IsOk() bool {
271
-
return s.StatusKind == StatusOk
272
-
}
273
-
274
-
func (s *InterdiffFileStatus) IsUnchanged() bool {
275
-
return s.StatusKind == StatusUnchanged
276
-
}
277
-
278
-
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
279
-
return s.StatusKind == StatusOnlyInOne
280
-
}
281
-
282
-
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
283
-
return s.StatusKind == StatusOnlyInTwo
284
-
}
285
-
286
-
func (s *InterdiffFileStatus) IsRebased() bool {
287
-
return s.StatusKind == StatusRebased
288
-
}
289
-
290
-
func (s *InterdiffFileStatus) IsError() bool {
291
-
return s.StatusKind == StatusError
292
-
}
293
-
294
-
type StatusKind int
295
-
296
-
func (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
-
313
-
const (
314
-
StatusOk StatusKind = iota
315
-
StatusOnlyInOne
316
-
StatusOnlyInTwo
317
-
StatusUnchanged
318
-
StatusRebased
319
-
StatusError
320
-
)
321
-
322
-
func 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
-
390
-
func 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
-
}
+168
patchutil/combinediff.go
+168
patchutil/combinediff.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
// original1 -> patch1 -> rev1
11
+
// original2 -> patch2 -> rev2
12
+
//
13
+
// original2 must be equal to rev1, so we can merge them to get maximal context
14
+
//
15
+
// finally,
16
+
// rev2' <- apply(patch2, merged)
17
+
// combineddiff <- diff(rev2', original1)
18
+
func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {
19
+
fileName := bestName(file1)
20
+
21
+
o1 := CreatePreImage(file1)
22
+
r1 := CreatePostImage(file1)
23
+
o2 := CreatePreImage(file2)
24
+
25
+
merged, err := r1.Merge(&o2)
26
+
if err != nil {
27
+
return nil, err
28
+
}
29
+
30
+
r2Prime, err := merged.Apply(file2)
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
// produce combined diff
36
+
diff, err := Unified(o1.String(), fileName, r2Prime, fileName)
37
+
if err != nil {
38
+
return nil, err
39
+
}
40
+
41
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
42
+
43
+
if len(parsed) != 1 {
44
+
// no diff? the second commit reverted the changes from the first
45
+
return nil, nil
46
+
}
47
+
48
+
return parsed[0], nil
49
+
}
50
+
51
+
// use empty lines for lines we are unaware of
52
+
//
53
+
// this raises an error only if the two patches were invalid or non-contiguous
54
+
func mergeLines(old, new string) (string, error) {
55
+
var i, j int
56
+
57
+
// TODO: use strings.Lines
58
+
linesOld := strings.Split(old, "\n")
59
+
linesNew := strings.Split(new, "\n")
60
+
61
+
result := []string{}
62
+
63
+
for i < len(linesOld) || j < len(linesNew) {
64
+
if i >= len(linesOld) {
65
+
// rest of the file is populated from `new`
66
+
result = append(result, linesNew[j])
67
+
j++
68
+
continue
69
+
}
70
+
71
+
if j >= len(linesNew) {
72
+
// rest of the file is populated from `old`
73
+
result = append(result, linesOld[i])
74
+
i++
75
+
continue
76
+
}
77
+
78
+
oldLine := linesOld[i]
79
+
newLine := linesNew[j]
80
+
81
+
if oldLine != newLine && (oldLine != "" && newLine != "") {
82
+
// context mismatch
83
+
return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine)
84
+
}
85
+
86
+
if oldLine == newLine {
87
+
result = append(result, oldLine)
88
+
} else if oldLine == "" {
89
+
result = append(result, newLine)
90
+
} else if newLine == "" {
91
+
result = append(result, oldLine)
92
+
}
93
+
i++
94
+
j++
95
+
}
96
+
97
+
return strings.Join(result, "\n"), nil
98
+
}
99
+
100
+
func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {
101
+
fileToIdx1 := make(map[string]int)
102
+
fileToIdx2 := make(map[string]int)
103
+
visited := make(map[string]struct{})
104
+
var result []*gitdiff.File
105
+
106
+
for idx, f := range patch1 {
107
+
fileToIdx1[bestName(f)] = idx
108
+
}
109
+
110
+
for idx, f := range patch2 {
111
+
fileToIdx2[bestName(f)] = idx
112
+
}
113
+
114
+
for _, f1 := range patch1 {
115
+
fileName := bestName(f1)
116
+
if idx, ok := fileToIdx2[fileName]; ok {
117
+
f2 := patch2[idx]
118
+
119
+
// we have f1 and f2, combine them
120
+
combined, err := combineFiles(f1, f2)
121
+
if err != nil {
122
+
fmt.Println(err)
123
+
}
124
+
125
+
result = append(result, combined)
126
+
} else {
127
+
// only in patch1; add as-is
128
+
result = append(result, f1)
129
+
}
130
+
131
+
visited[fileName] = struct{}{}
132
+
}
133
+
134
+
// for all files in patch2 that remain unvisited; we can just add them into the output
135
+
for _, f2 := range patch2 {
136
+
fileName := bestName(f2)
137
+
if _, ok := visited[fileName]; ok {
138
+
continue
139
+
}
140
+
141
+
result = append(result, f2)
142
+
}
143
+
144
+
return result
145
+
}
146
+
147
+
// pairwise combination from first to last patch
148
+
func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {
149
+
if len(patches) == 0 {
150
+
return nil
151
+
}
152
+
153
+
if len(patches) == 1 {
154
+
return patches[0]
155
+
}
156
+
157
+
combined := combineTwo(patches[0], patches[1])
158
+
159
+
newPatches := [][]*gitdiff.File{}
160
+
newPatches = append(newPatches, combined)
161
+
for i, p := range patches {
162
+
if i >= 2 {
163
+
newPatches = append(newPatches, p)
164
+
}
165
+
}
166
+
167
+
return CombineDiff(newPatches...)
168
+
}
+178
patchutil/image.go
+178
patchutil/image.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"strings"
7
+
8
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
+
)
10
+
11
+
type Line struct {
12
+
LineNumber int64
13
+
Content string
14
+
IsUnknown bool
15
+
}
16
+
17
+
func NewLineAt(lineNumber int64, content string) Line {
18
+
return Line{
19
+
LineNumber: lineNumber,
20
+
Content: content,
21
+
IsUnknown: false,
22
+
}
23
+
}
24
+
25
+
type Image struct {
26
+
File string
27
+
Data []*Line
28
+
}
29
+
30
+
func (r *Image) String() string {
31
+
var i, j int64
32
+
var b strings.Builder
33
+
for {
34
+
i += 1
35
+
36
+
if int(j) >= (len(r.Data)) {
37
+
break
38
+
}
39
+
40
+
if r.Data[j].LineNumber == i {
41
+
// b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
42
+
b.WriteString(r.Data[j].Content)
43
+
j += 1
44
+
} else {
45
+
//b.WriteString(fmt.Sprintf("%d:\n", i))
46
+
b.WriteString("\n")
47
+
}
48
+
}
49
+
50
+
return b.String()
51
+
}
52
+
53
+
func (r *Image) AddLine(line *Line) {
54
+
r.Data = append(r.Data, line)
55
+
}
56
+
57
+
// rebuild the original file from a patch
58
+
func CreatePreImage(file *gitdiff.File) Image {
59
+
rf := Image{
60
+
File: bestName(file),
61
+
}
62
+
63
+
for _, fragment := range file.TextFragments {
64
+
position := fragment.OldPosition
65
+
for _, line := range fragment.Lines {
66
+
switch line.Op {
67
+
case gitdiff.OpContext:
68
+
rl := NewLineAt(position, line.Line)
69
+
rf.Data = append(rf.Data, &rl)
70
+
position += 1
71
+
case gitdiff.OpDelete:
72
+
rl := NewLineAt(position, line.Line)
73
+
rf.Data = append(rf.Data, &rl)
74
+
position += 1
75
+
case gitdiff.OpAdd:
76
+
// do nothing here
77
+
}
78
+
}
79
+
}
80
+
81
+
return rf
82
+
}
83
+
84
+
// rebuild the revised file from a patch
85
+
func CreatePostImage(file *gitdiff.File) Image {
86
+
rf := Image{
87
+
File: bestName(file),
88
+
}
89
+
90
+
for _, fragment := range file.TextFragments {
91
+
position := fragment.NewPosition
92
+
for _, line := range fragment.Lines {
93
+
switch line.Op {
94
+
case gitdiff.OpContext:
95
+
rl := NewLineAt(position, line.Line)
96
+
rf.Data = append(rf.Data, &rl)
97
+
position += 1
98
+
case gitdiff.OpAdd:
99
+
rl := NewLineAt(position, line.Line)
100
+
rf.Data = append(rf.Data, &rl)
101
+
position += 1
102
+
case gitdiff.OpDelete:
103
+
// do nothing here
104
+
}
105
+
}
106
+
}
107
+
108
+
return rf
109
+
}
110
+
111
+
type MergeError struct {
112
+
msg string
113
+
mismatchingLine int64
114
+
}
115
+
116
+
func (m MergeError) Error() string {
117
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
118
+
}
119
+
120
+
// best effort merging of two reconstructed files
121
+
func (this *Image) Merge(other *Image) (*Image, error) {
122
+
mergedFile := Image{}
123
+
124
+
var i, j int64
125
+
126
+
for int(i) < len(this.Data) || int(j) < len(other.Data) {
127
+
if int(i) >= len(this.Data) {
128
+
// first file is done; the rest of the lines from file 2 can go in
129
+
mergedFile.AddLine(other.Data[j])
130
+
j++
131
+
continue
132
+
}
133
+
134
+
if int(j) >= len(other.Data) {
135
+
// first file is done; the rest of the lines from file 2 can go in
136
+
mergedFile.AddLine(this.Data[i])
137
+
i++
138
+
continue
139
+
}
140
+
141
+
line1 := this.Data[i]
142
+
line2 := other.Data[j]
143
+
144
+
if line1.LineNumber == line2.LineNumber {
145
+
if line1.Content != line2.Content {
146
+
return nil, MergeError{
147
+
msg: "mismatching lines, this patch might have undergone rebase",
148
+
mismatchingLine: line1.LineNumber,
149
+
}
150
+
} else {
151
+
mergedFile.AddLine(line1)
152
+
}
153
+
i++
154
+
j++
155
+
} else if line1.LineNumber < line2.LineNumber {
156
+
mergedFile.AddLine(line1)
157
+
i++
158
+
} else {
159
+
mergedFile.AddLine(line2)
160
+
j++
161
+
}
162
+
}
163
+
164
+
return &mergedFile, nil
165
+
}
166
+
167
+
func (r *Image) Apply(patch *gitdiff.File) (string, error) {
168
+
original := r.String()
169
+
var buffer bytes.Buffer
170
+
reader := strings.NewReader(original)
171
+
172
+
err := gitdiff.Apply(&buffer, reader, patch)
173
+
if err != nil {
174
+
return "", err
175
+
}
176
+
177
+
return buffer.String(), nil
178
+
}
+236
patchutil/interdiff.go
+236
patchutil/interdiff.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
type InterdiffResult struct {
11
+
Files []*InterdiffFile
12
+
}
13
+
14
+
func (i *InterdiffResult) String() string {
15
+
var b strings.Builder
16
+
for _, f := range i.Files {
17
+
b.WriteString(f.String())
18
+
b.WriteString("\n")
19
+
}
20
+
21
+
return b.String()
22
+
}
23
+
24
+
type InterdiffFile struct {
25
+
*gitdiff.File
26
+
Name string
27
+
Status InterdiffFileStatus
28
+
}
29
+
30
+
func (s *InterdiffFile) String() string {
31
+
var b strings.Builder
32
+
b.WriteString(s.Status.String())
33
+
b.WriteString(" ")
34
+
35
+
if s.File != nil {
36
+
b.WriteString(bestName(s.File))
37
+
b.WriteString("\n")
38
+
b.WriteString(s.File.String())
39
+
}
40
+
41
+
return b.String()
42
+
}
43
+
44
+
type InterdiffFileStatus struct {
45
+
StatusKind StatusKind
46
+
Error error
47
+
}
48
+
49
+
func (s *InterdiffFileStatus) String() string {
50
+
kind := s.StatusKind.String()
51
+
if s.Error != nil {
52
+
return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
53
+
} else {
54
+
return kind
55
+
}
56
+
}
57
+
58
+
func (s *InterdiffFileStatus) IsOk() bool {
59
+
return s.StatusKind == StatusOk
60
+
}
61
+
62
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
63
+
return s.StatusKind == StatusUnchanged
64
+
}
65
+
66
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
67
+
return s.StatusKind == StatusOnlyInOne
68
+
}
69
+
70
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
71
+
return s.StatusKind == StatusOnlyInTwo
72
+
}
73
+
74
+
func (s *InterdiffFileStatus) IsRebased() bool {
75
+
return s.StatusKind == StatusRebased
76
+
}
77
+
78
+
func (s *InterdiffFileStatus) IsError() bool {
79
+
return s.StatusKind == StatusError
80
+
}
81
+
82
+
type StatusKind int
83
+
84
+
func (k StatusKind) String() string {
85
+
switch k {
86
+
case StatusOnlyInOne:
87
+
return "only in one"
88
+
case StatusOnlyInTwo:
89
+
return "only in two"
90
+
case StatusUnchanged:
91
+
return "unchanged"
92
+
case StatusRebased:
93
+
return "rebased"
94
+
case StatusError:
95
+
return "error"
96
+
default:
97
+
return "changed"
98
+
}
99
+
}
100
+
101
+
const (
102
+
StatusOk StatusKind = iota
103
+
StatusOnlyInOne
104
+
StatusOnlyInTwo
105
+
StatusUnchanged
106
+
StatusRebased
107
+
StatusError
108
+
)
109
+
110
+
func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
111
+
re1 := CreatePreImage(f1)
112
+
re2 := CreatePreImage(f2)
113
+
114
+
interdiffFile := InterdiffFile{
115
+
Name: bestName(f1),
116
+
}
117
+
118
+
merged, err := re1.Merge(&re2)
119
+
if err != nil {
120
+
interdiffFile.Status = InterdiffFileStatus{
121
+
StatusKind: StatusRebased,
122
+
Error: err,
123
+
}
124
+
return &interdiffFile
125
+
}
126
+
127
+
rev1, err := merged.Apply(f1)
128
+
if err != nil {
129
+
interdiffFile.Status = InterdiffFileStatus{
130
+
StatusKind: StatusError,
131
+
Error: err,
132
+
}
133
+
return &interdiffFile
134
+
}
135
+
136
+
rev2, err := merged.Apply(f2)
137
+
if err != nil {
138
+
interdiffFile.Status = InterdiffFileStatus{
139
+
StatusKind: StatusError,
140
+
Error: err,
141
+
}
142
+
return &interdiffFile
143
+
}
144
+
145
+
diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
146
+
if err != nil {
147
+
interdiffFile.Status = InterdiffFileStatus{
148
+
StatusKind: StatusError,
149
+
Error: err,
150
+
}
151
+
return &interdiffFile
152
+
}
153
+
154
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
155
+
if err != nil {
156
+
interdiffFile.Status = InterdiffFileStatus{
157
+
StatusKind: StatusError,
158
+
Error: err,
159
+
}
160
+
return &interdiffFile
161
+
}
162
+
163
+
if len(parsed) != 1 {
164
+
// files are identical?
165
+
interdiffFile.Status = InterdiffFileStatus{
166
+
StatusKind: StatusUnchanged,
167
+
}
168
+
return &interdiffFile
169
+
}
170
+
171
+
if interdiffFile.Status.StatusKind == StatusOk {
172
+
interdiffFile.File = parsed[0]
173
+
}
174
+
175
+
return &interdiffFile
176
+
}
177
+
178
+
func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
179
+
fileToIdx1 := make(map[string]int)
180
+
fileToIdx2 := make(map[string]int)
181
+
visited := make(map[string]struct{})
182
+
var result InterdiffResult
183
+
184
+
for idx, f := range patch1 {
185
+
fileToIdx1[bestName(f)] = idx
186
+
}
187
+
188
+
for idx, f := range patch2 {
189
+
fileToIdx2[bestName(f)] = idx
190
+
}
191
+
192
+
for _, f1 := range patch1 {
193
+
var interdiffFile *InterdiffFile
194
+
195
+
fileName := bestName(f1)
196
+
if idx, ok := fileToIdx2[fileName]; ok {
197
+
f2 := patch2[idx]
198
+
199
+
// we have f1 and f2, calculate interdiff
200
+
interdiffFile = interdiffFiles(f1, f2)
201
+
} else {
202
+
// only in patch 1, this change would have to be "inverted" to dissapear
203
+
// from patch 2, so we reverseDiff(f1)
204
+
reverseDiff(f1)
205
+
206
+
interdiffFile = &InterdiffFile{
207
+
File: f1,
208
+
Name: fileName,
209
+
Status: InterdiffFileStatus{
210
+
StatusKind: StatusOnlyInOne,
211
+
},
212
+
}
213
+
}
214
+
215
+
result.Files = append(result.Files, interdiffFile)
216
+
visited[fileName] = struct{}{}
217
+
}
218
+
219
+
// for all files in patch2 that remain unvisited; we can just add them into the output
220
+
for _, f2 := range patch2 {
221
+
fileName := bestName(f2)
222
+
if _, ok := visited[fileName]; ok {
223
+
continue
224
+
}
225
+
226
+
result.Files = append(result.Files, &InterdiffFile{
227
+
File: f2,
228
+
Name: fileName,
229
+
Status: InterdiffFileStatus{
230
+
StatusKind: StatusOnlyInTwo,
231
+
},
232
+
})
233
+
}
234
+
235
+
return &result
236
+
}
+69
patchutil/patchutil.go
+69
patchutil/patchutil.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"os"
6
+
"os/exec"
5
7
"regexp"
6
8
"strings"
7
9
···
125
127
}
126
128
return patches
127
129
}
130
+
131
+
func bestName(file *gitdiff.File) string {
132
+
if file.IsDelete {
133
+
return file.OldName
134
+
} else {
135
+
return file.NewName
136
+
}
137
+
}
138
+
139
+
// in-place reverse of a diff
140
+
func reverseDiff(file *gitdiff.File) {
141
+
file.OldName, file.NewName = file.NewName, file.OldName
142
+
file.OldMode, file.NewMode = file.NewMode, file.OldMode
143
+
file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
144
+
145
+
for _, fragment := range file.TextFragments {
146
+
// swap postions
147
+
fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
148
+
fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
149
+
fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
150
+
151
+
for i := range fragment.Lines {
152
+
switch fragment.Lines[i].Op {
153
+
case gitdiff.OpAdd:
154
+
fragment.Lines[i].Op = gitdiff.OpDelete
155
+
case gitdiff.OpDelete:
156
+
fragment.Lines[i].Op = gitdiff.OpAdd
157
+
default:
158
+
// do nothing
159
+
}
160
+
}
161
+
}
162
+
}
163
+
164
+
func Unified(oldText, oldFile, newText, newFile string) (string, error) {
165
+
oldTemp, err := os.CreateTemp("", "old_*")
166
+
if err != nil {
167
+
return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
168
+
}
169
+
defer os.Remove(oldTemp.Name())
170
+
if _, err := oldTemp.WriteString(oldText); err != nil {
171
+
return "", fmt.Errorf("failed to write to old temp file: %w", err)
172
+
}
173
+
oldTemp.Close()
174
+
175
+
newTemp, err := os.CreateTemp("", "new_*")
176
+
if err != nil {
177
+
return "", fmt.Errorf("failed to create temp file for newText: %w", err)
178
+
}
179
+
defer os.Remove(newTemp.Name())
180
+
if _, err := newTemp.WriteString(newText); err != nil {
181
+
return "", fmt.Errorf("failed to write to new temp file: %w", err)
182
+
}
183
+
newTemp.Close()
184
+
185
+
cmd := exec.Command("diff", "-U", "9999", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
186
+
output, err := cmd.CombinedOutput()
187
+
188
+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
189
+
return string(output), nil
190
+
}
191
+
if err != nil {
192
+
return "", fmt.Errorf("diff command failed: %w", err)
193
+
}
194
+
195
+
return string(output), nil
196
+
}