Monorepo for Tangled tangled.org

appview: implement interdiff

takes a lot of inspiration from patchutils' interdiff algorithm. unlike gerrit; rebase detection is very much a work in progress.

authored by oppi.li and committed by Tangled 889c121f fac0abc1

Changed files
+1038 -23
appview
db
pages
templates
repo
state
cmd
combinediff
interdiff
patchutil
+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 }
+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/patchutil" 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 *patchutil.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 {
+148
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">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 }}</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 {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 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.IsRebased }} 78 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 79 + This patch was likely rebased, as context lines do not match. 80 + </p> 81 + {{ else if .Status.IsError }} 82 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 83 + Failed to calculate interdiff for this file. 84 + </p> 85 + {{ else }} 86 + {{ $name := .Name }} 87 + <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> 88 + {{- $oldStart := .OldPosition -}} 89 + {{- $newStart := .NewPosition -}} 90 + {{- $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 " -}} 91 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 92 + {{- $lineNrSepStyle1 := "" -}} 93 + {{- $lineNrSepStyle2 := "pr-2" -}} 94 + {{- range .Lines -}} 95 + {{- if eq .Op.String "+" -}} 96 + <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 97 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 98 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 99 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 100 + <div class="px-2">{{ .Line }}</div> 101 + </div> 102 + {{- $newStart = add64 $newStart 1 -}} 103 + {{- end -}} 104 + {{- if eq .Op.String "-" -}} 105 + <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 106 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 107 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 108 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 109 + <div class="px-2">{{ .Line }}</div> 110 + </div> 111 + {{- $oldStart = add64 $oldStart 1 -}} 112 + {{- end -}} 113 + {{- if eq .Op.String " " -}} 114 + <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 115 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 116 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 117 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 118 + <div class="px-2">{{ .Line }}</div> 119 + </div> 120 + {{- $newStart = add64 $newStart 1 -}} 121 + {{- $oldStart = add64 $oldStart 1 -}} 122 + {{- end -}} 123 + {{- end -}} 124 + {{- end -}}</div></div></pre> 125 + {{- end -}} 126 + </div> 127 + 128 + </details> 129 + 130 + </div> 131 + </div> 132 + </section> 133 + {{ end }} 134 + {{ end }} 135 + {{ end }} 136 + 137 + {{ define "statPill" }} 138 + <div class="flex items-center font-mono text-sm"> 139 + {{ if and .Insertions .Deletions }} 140 + <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> 141 + <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> 142 + {{ else if .Insertions }} 143 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 144 + {{ else if .Deletions }} 145 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 146 + {{ end }} 147 + </div> 148 + {{ end }}
+25
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 class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white"> 7 + <header class="pb-2"> 8 + <div class="flex gap-3 items-center mb-3"> 9 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 10 + {{ i "arrow-left" "w-5 h-5" }} 11 + back 12 + </a> 13 + <span class="select-none before:content-['\00B7']"></span> 14 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} 15 + </div> 16 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 17 + {{ template "repo/pulls/fragments/pullHeader" . }} 18 + </header> 19 + </section> 20 + 21 + <section> 22 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 23 + </section> 24 + {{ end }} 25 +
+7 -2
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> 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> 61 66 {{ end }} 62 67 </div> 63 68 </summary>
+69 -1
appview/state/pull.go
··· 12 12 "strconv" 13 13 "time" 14 14 15 - "github.com/go-chi/chi/v5" 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" ··· 23 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 23 "github.com/bluesky-social/indigo/atproto/syntax" 25 24 lexutil "github.com/bluesky-social/indigo/lex/util" 25 + "github.com/go-chi/chi/v5" 26 26 ) 27 27 28 28 // htmx fragment ··· 305 305 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 306 306 }) 307 307 308 + } 309 + 310 + func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 311 + user := s.auth.GetUser(r) 312 + 313 + f, err := fullyResolvedRepo(r) 314 + if err != nil { 315 + log.Println("failed to get repo and knot", err) 316 + return 317 + } 318 + 319 + pull, ok := r.Context().Value("pull").(*db.Pull) 320 + if !ok { 321 + log.Println("failed to get pull") 322 + s.pages.Notice(w, "pull-error", "Failed to get pull.") 323 + return 324 + } 325 + 326 + roundId := chi.URLParam(r, "round") 327 + roundIdInt, err := strconv.Atoi(roundId) 328 + if err != nil || roundIdInt >= len(pull.Submissions) { 329 + http.Error(w, "bad round id", http.StatusBadRequest) 330 + log.Println("failed to parse round id", err) 331 + return 332 + } 333 + 334 + if roundIdInt == 0 { 335 + http.Error(w, "bad round id", http.StatusBadRequest) 336 + log.Println("cannot interdiff initial submission") 337 + return 338 + } 339 + 340 + identsToResolve := []string{pull.OwnerDid} 341 + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 342 + didHandleMap := make(map[string]string) 343 + for _, identity := range resolvedIds { 344 + if !identity.Handle.IsInvalidHandle() { 345 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 346 + } else { 347 + didHandleMap[identity.DID.String()] = identity.DID.String() 348 + } 349 + } 350 + 351 + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 352 + if err != nil { 353 + log.Println("failed to interdiff; current patch malformed") 354 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 355 + return 356 + } 357 + 358 + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 359 + if err != nil { 360 + log.Println("failed to interdiff; previous patch malformed") 361 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 362 + return 363 + } 364 + 365 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 366 + 367 + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 368 + LoggedInUser: s.auth.GetUser(r), 369 + RepoInfo: f.RepoInfo(s, user), 370 + Pull: pull, 371 + Round: roundIdInt, 372 + DidHandleMap: didHandleMap, 373 + Interdiff: interdiff, 374 + }) 375 + return 308 376 } 309 377 310 378 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)
+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 + }
+38
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/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: interdiff <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 + interDiffResult := patchutil.Interdiff(files1, files2) 37 + fmt.Println(interDiffResult) 38 + }
+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=
+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
··· 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
··· 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
··· 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 + }