forked from tangled.org/core
Monorepo for Tangled

appview: implement interdiff

this is pretty garbage, hard to read and pretty sure it dosent work

Changed files
+711 -19
appview
pages
templates
repo
state
cmd
interdiff
interdiff
+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
··· 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">&middot;&middot;&middot;</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
··· 1 + {{ define "title" }} 2 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .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
··· 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
··· 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
··· 109 109 110 110 r.Route("/round/{round}", func(r chi.Router) { 111 111 r.Get("/", s.RepoPullPatch) 112 + r.Get("/interdiff", s.RepoPullInterdiff) 112 113 r.Get("/actions", s.PullActions) 113 114 r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) { 114 115 r.Get("/", s.PullComment)
+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
··· 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
··· 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
··· 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 + }