+21
-5
appview/pages/pages.go
+21
-5
appview/pages/pages.go
···
16
16
"slices"
17
17
"strings"
18
18
19
+
"tangled.sh/tangled.sh/core/appview/auth"
20
+
"tangled.sh/tangled.sh/core/appview/db"
21
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
23
+
"tangled.sh/tangled.sh/core/interdiff"
24
+
"tangled.sh/tangled.sh/core/types"
25
+
19
26
"github.com/alecthomas/chroma/v2"
20
27
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
21
28
"github.com/alecthomas/chroma/v2/lexers"
22
29
"github.com/alecthomas/chroma/v2/styles"
23
30
"github.com/bluesky-social/indigo/atproto/syntax"
24
31
"github.com/microcosm-cc/bluemonday"
25
-
"tangled.sh/tangled.sh/core/appview/auth"
26
-
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
29
-
"tangled.sh/tangled.sh/core/types"
30
32
)
31
33
32
34
//go:embed templates/* static
···
705
707
// this name is a mouthful
706
708
func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
707
709
return p.execute("repo/pulls/patch", w, params)
710
+
}
711
+
712
+
type RepoPullInterdiffParams struct {
713
+
LoggedInUser *auth.User
714
+
DidHandleMap map[string]string
715
+
RepoInfo RepoInfo
716
+
Pull *db.Pull
717
+
Round int
718
+
Interdiff *interdiff.InterdiffResult
719
+
}
720
+
721
+
// this name is a mouthful
722
+
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
723
+
return p.execute("repo/pulls/interdiff", w, params)
708
724
}
709
725
710
726
type PullPatchUploadParams struct {
+144
appview/pages/templates/repo/fragments/interdiff.html
+144
appview/pages/templates/repo/fragments/interdiff.html
···
1
+
{{ define "repo/fragments/interdiff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $x := index . 1 }}
4
+
{{ $diff := $x.Files }}
5
+
6
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
7
+
<div class="diff-stat">
8
+
<div class="flex gap-2 items-center">
9
+
<strong class="text-sm uppercase dark:text-gray-200">affected files</strong>
10
+
</div>
11
+
<div class="overflow-x-auto">
12
+
<ul class="dark:text-gray-200">
13
+
{{ range $diff }}
14
+
<li><a href="#file-{{ .Name }}" class="dark:hover:text-gray-300">{{ .Name }} ({{ .Status.StatusKind }})</a></li>
15
+
{{ end }}
16
+
</ul>
17
+
</div>
18
+
</div>
19
+
</section>
20
+
21
+
{{ $last := sub (len $diff) 1 }}
22
+
{{ range $idx, $hunk := $diff }}
23
+
{{ with $hunk }}
24
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
25
+
<div id="file-{{ .Name }}">
26
+
<div id="diff-file">
27
+
<details open>
28
+
<summary class="list-none cursor-pointer sticky top-0">
29
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
30
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
31
+
<div class="flex gap-1 items-center" style="direction: ltr;">
32
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
33
+
{{ if .Status.IsOk }}
34
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
35
+
{{ else if .Status.IsUnchanged }}
36
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
37
+
{{ else if .Status.IsOnlyInOne }}
38
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
39
+
{{ else if .Status.IsOnlyInTwo }}
40
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
41
+
{{ else if .Status.IsRebased }}
42
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
43
+
{{ else }}
44
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
45
+
{{ end }}
46
+
</div>
47
+
48
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
49
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
50
+
{{ .Name }}
51
+
</a>
52
+
</div>
53
+
</div>
54
+
55
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
56
+
<div id="right-side-items" class="p-2 flex items-center">
57
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
58
+
{{ if gt $idx 0 }}
59
+
{{ $prev := index $diff (sub $idx 1) }}
60
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
61
+
{{ end }}
62
+
63
+
{{ if lt $idx $last }}
64
+
{{ $next := index $diff (add $idx 1) }}
65
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
66
+
{{ end }}
67
+
</div>
68
+
69
+
</div>
70
+
</summary>
71
+
72
+
<div class="transition-all duration-700 ease-in-out">
73
+
{{ if .Status.IsUnchanged }}
74
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
75
+
This file has not been changed.
76
+
</p>
77
+
{{ else if .Status.IsError }}
78
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
79
+
Failed to calculate interdiff for this file.
80
+
</p>
81
+
{{ else }}
82
+
{{ $name := .Name }}
83
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
84
+
{{- $oldStart := .OldPosition -}}
85
+
{{- $newStart := .NewPosition -}}
86
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
87
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
88
+
{{- $lineNrSepStyle1 := "" -}}
89
+
{{- $lineNrSepStyle2 := "pr-2" -}}
90
+
{{- range .Lines -}}
91
+
{{- if eq .Op.String "+" -}}
92
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
93
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
94
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
95
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
96
+
<div class="px-2">{{ .Line }}</div>
97
+
</div>
98
+
{{- $newStart = add64 $newStart 1 -}}
99
+
{{- end -}}
100
+
{{- if eq .Op.String "-" -}}
101
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
102
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
103
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
104
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
105
+
<div class="px-2">{{ .Line }}</div>
106
+
</div>
107
+
{{- $oldStart = add64 $oldStart 1 -}}
108
+
{{- end -}}
109
+
{{- if eq .Op.String " " -}}
110
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
111
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
112
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
113
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
114
+
<div class="px-2">{{ .Line }}</div>
115
+
</div>
116
+
{{- $newStart = add64 $newStart 1 -}}
117
+
{{- $oldStart = add64 $oldStart 1 -}}
118
+
{{- end -}}
119
+
{{- end -}}
120
+
{{- end -}}</div></div></pre>
121
+
{{- end -}}
122
+
</div>
123
+
124
+
</details>
125
+
126
+
</div>
127
+
</div>
128
+
</section>
129
+
{{ end }}
130
+
{{ end }}
131
+
{{ end }}
132
+
133
+
{{ define "statPill" }}
134
+
<div class="flex items-center font-mono text-sm">
135
+
{{ if and .Insertions .Deletions }}
136
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
137
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
138
+
{{ else if .Insertions }}
139
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
140
+
{{ else if .Deletions }}
141
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
142
+
{{ end }}
143
+
</div>
144
+
{{ end }}
+10
appview/pages/templates/repo/pulls/interdiff.html
+10
appview/pages/templates/repo/pulls/interdiff.html
···
1
+
{{ define "title" }}
2
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
+
{{ end }}
4
+
5
+
{{ define "content" }}
6
+
<section>
7
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
8
+
</section>
9
+
{{ end }}
10
+
+7
appview/pages/templates/repo/pulls/pull.html
+7
appview/pages/templates/repo/pulls/pull.html
···
58
58
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
59
59
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
60
60
</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
+
{{ end }}
61
68
{{ end }}
62
69
</div>
63
70
</summary>
+64
-1
appview/state/pull.go
+64
-1
appview/state/pull.go
···
10
10
"net/http"
11
11
"net/url"
12
12
"strconv"
13
+
"strings"
13
14
"time"
14
15
15
-
"github.com/go-chi/chi/v5"
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
17
"tangled.sh/tangled.sh/core/appview/auth"
18
18
"tangled.sh/tangled.sh/core/appview/db"
19
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/interdiff"
20
21
"tangled.sh/tangled.sh/core/patchutil"
21
22
"tangled.sh/tangled.sh/core/types"
22
23
24
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
23
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
26
"github.com/bluesky-social/indigo/atproto/syntax"
25
27
lexutil "github.com/bluesky-social/indigo/lex/util"
28
+
"github.com/go-chi/chi/v5"
26
29
)
27
30
28
31
// htmx fragment
···
305
308
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
306
309
})
307
310
311
+
}
312
+
313
+
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
314
+
user := s.auth.GetUser(r)
315
+
316
+
f, err := fullyResolvedRepo(r)
317
+
if err != nil {
318
+
log.Println("failed to get repo and knot", err)
319
+
return
320
+
}
321
+
322
+
pull, ok := r.Context().Value("pull").(*db.Pull)
323
+
if !ok {
324
+
log.Println("failed to get pull")
325
+
s.pages.Notice(w, "pull-error", "Failed to get pull.")
326
+
return
327
+
}
328
+
329
+
roundId := chi.URLParam(r, "round")
330
+
roundIdInt, err := strconv.Atoi(roundId)
331
+
if err != nil || roundIdInt >= len(pull.Submissions) {
332
+
http.Error(w, "bad round id", http.StatusBadRequest)
333
+
log.Println("failed to parse round id", err)
334
+
return
335
+
}
336
+
337
+
if roundIdInt == 0 {
338
+
http.Error(w, "bad round id", http.StatusBadRequest)
339
+
log.Println("cannot interdiff initial submission")
340
+
return
341
+
}
342
+
343
+
identsToResolve := []string{pull.OwnerDid}
344
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
345
+
didHandleMap := make(map[string]string)
346
+
for _, identity := range resolvedIds {
347
+
if !identity.Handle.IsInvalidHandle() {
348
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
349
+
} else {
350
+
didHandleMap[identity.DID.String()] = identity.DID.String()
351
+
}
352
+
}
353
+
354
+
currentPatch, _, _ := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt].Patch))
355
+
previousPatch, _, _ := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt-1].Patch))
356
+
357
+
interdiff := interdiff.Interdiff(previousPatch, currentPatch)
358
+
359
+
for _, f := range interdiff.Files {
360
+
fmt.Printf("%s, %+v\n-----", f.Name, f.File)
361
+
}
362
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
363
+
LoggedInUser: s.auth.GetUser(r),
364
+
RepoInfo: f.RepoInfo(s, user),
365
+
Pull: pull,
366
+
Round: roundIdInt,
367
+
DidHandleMap: didHandleMap,
368
+
Interdiff: interdiff,
369
+
})
370
+
return
308
371
}
309
372
310
373
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
+1
appview/state/router.go
+1
appview/state/router.go
+33
cmd/interdiff/main.go
+33
cmd/interdiff/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/interdiff"
9
+
)
10
+
11
+
func main() {
12
+
patch1, err := os.Open("patches/g1.patch")
13
+
if err != nil {
14
+
fmt.Println(err)
15
+
}
16
+
patch2, err := os.Open("patches/g2.patch")
17
+
if err != nil {
18
+
fmt.Println(err)
19
+
}
20
+
21
+
files1, _, err := gitdiff.Parse(patch1)
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files2, _, err := gitdiff.Parse(patch2)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
interDiffResult := interdiff.Interdiff(files1, files2)
32
+
fmt.Println(interDiffResult)
33
+
}
+3
-3
go.mod
+3
-3
go.mod
···
106
106
go.uber.org/atomic v1.11.0 // indirect
107
107
go.uber.org/multierr v1.11.0 // indirect
108
108
go.uber.org/zap v1.26.0 // indirect
109
-
golang.org/x/crypto v0.36.0 // indirect
110
-
golang.org/x/net v0.37.0 // indirect
111
-
golang.org/x/sys v0.31.0 // indirect
109
+
golang.org/x/crypto v0.37.0 // indirect
110
+
golang.org/x/net v0.39.0 // indirect
111
+
golang.org/x/sys v0.32.0 // indirect
112
112
golang.org/x/time v0.5.0 // indirect
113
113
google.golang.org/protobuf v1.34.2 // indirect
114
114
gopkg.in/warnings.v0 v0.1.2 // indirect
+10
-10
go.sum
+10
-10
go.sum
···
303
303
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
304
304
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
305
305
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
306
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
307
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
306
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
307
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
308
308
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
309
309
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
310
310
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
327
327
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
328
328
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
329
329
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
330
-
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
331
-
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
330
+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
331
+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
332
332
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
333
333
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
334
334
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
357
357
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
358
358
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
359
359
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
360
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
361
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
360
+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
361
+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
362
362
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
363
363
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
364
364
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
365
365
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
366
366
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
367
-
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
368
-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
367
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
368
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
369
369
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
370
370
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
371
371
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
372
372
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
373
373
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
374
374
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
375
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
376
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
375
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
376
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
377
377
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
378
378
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
379
379
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+418
interdiff/interdiff.go
+418
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
+
// rebuild the original file from a patch
68
+
func 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
+
94
+
type MergeError struct {
95
+
msg string
96
+
mismatchingLines []int64
97
+
}
98
+
99
+
func (m MergeError) Error() string {
100
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLines)
101
+
}
102
+
103
+
// best effort merging of two reconstructed files
104
+
func (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
+
155
+
func (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
+
168
+
func 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
+
202
+
type InterdiffResult struct {
203
+
Files []*InterdiffFile
204
+
}
205
+
206
+
func (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
+
216
+
type InterdiffFile struct {
217
+
*gitdiff.File
218
+
Name string
219
+
Status InterdiffFileStatus
220
+
}
221
+
222
+
func (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
+
236
+
type InterdiffFileStatus struct {
237
+
StatusKind StatusKind
238
+
Error error
239
+
}
240
+
241
+
func (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
+
250
+
func (s *InterdiffFileStatus) IsOk() bool {
251
+
return s.StatusKind == StatusOk
252
+
}
253
+
254
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
255
+
return s.StatusKind == StatusUnchanged
256
+
}
257
+
258
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
259
+
return s.StatusKind == StatusOnlyInOne
260
+
}
261
+
262
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
263
+
return s.StatusKind == StatusOnlyInTwo
264
+
}
265
+
266
+
func (s *InterdiffFileStatus) IsRebased() bool {
267
+
return s.StatusKind == StatusRebased
268
+
}
269
+
270
+
func (s *InterdiffFileStatus) IsError() bool {
271
+
return s.StatusKind == StatusError
272
+
}
273
+
274
+
type StatusKind int
275
+
276
+
func (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
+
293
+
const (
294
+
StatusOk StatusKind = iota
295
+
StatusOnlyInOne
296
+
StatusOnlyInTwo
297
+
StatusUnchanged
298
+
StatusRebased
299
+
StatusError
300
+
)
301
+
302
+
func 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
+
365
+
func 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
+
}