From 889c121f7eb8a21bb0828ae524922641c67d473d Mon Sep 17 00:00:00 2001 From: "oppili.bsky.social" Date: Mon, 21 Apr 2025 18:49:58 +0000 Subject: [PATCH] appview: implement interdiff takes a lot of inspiration from patchutils' interdiff algorithm. unlike gerrit; rebase detection is very much a work in progress. --- appview/db/pulls.go | 29 ++- appview/pages/pages.go | 26 +- .../templates/repo/fragments/interdiff.html | 148 +++++++++++ .../pages/templates/repo/pulls/interdiff.html | 25 ++ appview/pages/templates/repo/pulls/pull.html | 9 +- appview/state/pull.go | 70 +++++- appview/state/router.go | 1 + cmd/combinediff/main.go | 38 +++ cmd/interdiff/main.go | 38 +++ go.mod | 6 +- go.sum | 20 +- patchutil/combinediff.go | 168 +++++++++++++ patchutil/image.go | 178 +++++++++++++ patchutil/interdiff.go | 236 ++++++++++++++++++ patchutil/patchutil.go | 69 +++++ 15 files changed, 1038 insertions(+), 23 deletions(-) create mode 100644 appview/pages/templates/repo/fragments/interdiff.html create mode 100644 appview/pages/templates/repo/pulls/interdiff.html create mode 100644 cmd/combinediff/main.go create mode 100644 cmd/interdiff/main.go create mode 100644 patchutil/combinediff.go create mode 100644 patchutil/image.go create mode 100644 patchutil/interdiff.go diff --git a/appview/db/pulls.go b/appview/db/pulls.go index 6379fa4..ac35efa 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -150,10 +150,35 @@ func (p *Pull) IsForkBased() bool { return false } -func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { +func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { patch := s.Patch - diffs, _, err := gitdiff.Parse(strings.NewReader(patch)) + // if format-patch; then extract each patch + var diffs []*gitdiff.File + if patchutil.IsFormatPatch(patch) { + patches, err := patchutil.ExtractPatches(patch) + if err != nil { + return nil, err + } + var ps [][]*gitdiff.File + for _, p := range patches { + ps = append(ps, p.Files) + } + + diffs = patchutil.CombineDiff(ps...) + } else { + d, _, err := gitdiff.Parse(strings.NewReader(patch)) + if err != nil { + return nil, err + } + diffs = d + } + + return diffs, nil +} + +func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { + diffs, err := s.AsDiff(targetBranch) if err != nil { log.Println(err) } diff --git a/appview/pages/pages.go b/appview/pages/pages.go index f3e3d9d..93e8117 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -16,17 +16,19 @@ import ( "slices" "strings" + "tangled.sh/tangled.sh/core/appview/auth" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/pages/markup" + "tangled.sh/tangled.sh/core/appview/state/userutil" + "tangled.sh/tangled.sh/core/patchutil" + "tangled.sh/tangled.sh/core/types" + "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/microcosm-cc/bluemonday" - "tangled.sh/tangled.sh/core/appview/auth" - "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/pages/markup" - "tangled.sh/tangled.sh/core/appview/state/userutil" - "tangled.sh/tangled.sh/core/types" ) //go:embed templates/* static @@ -707,6 +709,20 @@ func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error return p.execute("repo/pulls/patch", w, params) } +type RepoPullInterdiffParams struct { + LoggedInUser *auth.User + DidHandleMap map[string]string + RepoInfo RepoInfo + Pull *db.Pull + Round int + Interdiff *patchutil.InterdiffResult +} + +// this name is a mouthful +func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { + return p.execute("repo/pulls/interdiff", w, params) +} + type PullPatchUploadParams struct { RepoInfo RepoInfo } diff --git a/appview/pages/templates/repo/fragments/interdiff.html b/appview/pages/templates/repo/fragments/interdiff.html new file mode 100644 index 0000000..f9fd6e5 --- /dev/null +++ b/appview/pages/templates/repo/fragments/interdiff.html @@ -0,0 +1,148 @@ +{{ define "repo/fragments/interdiff" }} +{{ $repo := index . 0 }} +{{ $x := index . 1 }} +{{ $diff := $x.Files }} + +
+
+
+ files +
+
+ +
+
+
+ + {{ $last := sub (len $diff) 1 }} + {{ range $idx, $hunk := $diff }} + {{ with $hunk }} +
+
+
+
+ +
+
+
+ {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} + {{ if .Status.IsOk }} + CHANGED + {{ else if .Status.IsUnchanged }} + UNCHANGED + {{ else if .Status.IsOnlyInOne }} + REVERTED + {{ else if .Status.IsOnlyInTwo }} + NEW + {{ else if .Status.IsRebased }} + REBASED + {{ else }} + ERROR + {{ end }} +
+ + +
+ + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} +
+ {{ i "arrow-up-to-line" "w-4 h-4" }} + {{ if gt $idx 0 }} + {{ $prev := index $diff (sub $idx 1) }} + {{ i "arrow-up" "w-4 h-4" }} + {{ end }} + + {{ if lt $idx $last }} + {{ $next := index $diff (add $idx 1) }} + {{ i "arrow-down" "w-4 h-4" }} + {{ end }} +
+ +
+
+ +
+ {{ if .Status.IsUnchanged }} +

+ This file has not been changed. +

+ {{ else if .Status.IsRebased }} +

+ This patch was likely rebased, as context lines do not match. +

+ {{ else if .Status.IsError }} +

+ Failed to calculate interdiff for this file. +

+ {{ else }} + {{ $name := .Name }} +
{{- range .TextFragments -}}
···
+ {{- $oldStart := .OldPosition -}} + {{- $newStart := .NewPosition -}} + {{- $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 " -}} + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} + {{- $lineNrSepStyle1 := "" -}} + {{- $lineNrSepStyle2 := "pr-2" -}} + {{- range .Lines -}} + {{- if eq .Op.String "+" -}} +
+
+ +
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- $newStart = add64 $newStart 1 -}} + {{- end -}} + {{- if eq .Op.String "-" -}} +
+ +
+
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- $oldStart = add64 $oldStart 1 -}} + {{- end -}} + {{- if eq .Op.String " " -}} +
+ + +
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- $newStart = add64 $newStart 1 -}} + {{- $oldStart = add64 $oldStart 1 -}} + {{- end -}} + {{- end -}} + {{- end -}}
+ {{- end -}} +
+ +
+ +
+
+
+ {{ end }} + {{ end }} +{{ end }} + +{{ define "statPill" }} +
+ {{ if and .Insertions .Deletions }} + +{{ .Insertions }} + -{{ .Deletions }} + {{ else if .Insertions }} + +{{ .Insertions }} + {{ else if .Deletions }} + -{{ .Deletions }} + {{ end }} +
+{{ end }} diff --git a/appview/pages/templates/repo/pulls/interdiff.html b/appview/pages/templates/repo/pulls/interdiff.html new file mode 100644 index 0000000..c333be1 --- /dev/null +++ b/appview/pages/templates/repo/pulls/interdiff.html @@ -0,0 +1,25 @@ +{{ define "title" }} + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }} +{{ end }} + +{{ define "content" }} +
+
+
+ + {{ i "arrow-left" "w-5 h-5" }} + back + + + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} +
+
+ {{ template "repo/pulls/fragments/pullHeader" . }} +
+
+ +
+ {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} +
+{{ end }} + diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index ab67cde..d692e3b 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -51,13 +51,18 @@ - {{ if $.Pull.IsPatchBased }} - {{ i "file-diff" "w-4 h-4" }} + {{ if not (eq .RoundNumber 0) }} + + {{ i "file-diff" "w-4 h-4" }} + + {{ end }} diff --git a/appview/state/pull.go b/appview/state/pull.go index bb9531e..fc04a99 100644 --- a/appview/state/pull.go +++ b/appview/state/pull.go @@ -12,7 +12,6 @@ import ( "strconv" "time" - "github.com/go-chi/chi/v5" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/auth" "tangled.sh/tangled.sh/core/appview/db" @@ -23,6 +22,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" ) // htmx fragment @@ -307,6 +307,74 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { } +func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to get pull.") + return + } + + roundId := chi.URLParam(r, "round") + roundIdInt, err := strconv.Atoi(roundId) + if err != nil || roundIdInt >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + if roundIdInt == 0 { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("cannot interdiff initial submission") + return + } + + identsToResolve := []string{pull.OwnerDid} + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) + if err != nil { + log.Println("failed to interdiff; current patch malformed") + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") + return + } + + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) + if err != nil { + log.Println("failed to interdiff; previous patch malformed") + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") + return + } + + interdiff := patchutil.Interdiff(previousPatch, currentPatch) + + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ + LoggedInUser: s.auth.GetUser(r), + RepoInfo: f.RepoInfo(s, user), + Pull: pull, + Round: roundIdInt, + DidHandleMap: didHandleMap, + Interdiff: interdiff, + }) + return +} + func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { pull, ok := r.Context().Value("pull").(*db.Pull) if !ok { diff --git a/appview/state/router.go b/appview/state/router.go index 49ed7f3..92e87b8 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -109,6 +109,7 @@ func (s *State) UserRouter() http.Handler { r.Route("/round/{round}", func(r chi.Router) { r.Get("/", s.RepoPullPatch) + r.Get("/interdiff", s.RepoPullInterdiff) r.Get("/actions", s.PullActions) r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) { r.Get("/", s.PullComment) diff --git a/cmd/combinediff/main.go b/cmd/combinediff/main.go new file mode 100644 index 0000000..6f5d7ab --- /dev/null +++ b/cmd/combinediff/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "tangled.sh/tangled.sh/core/patchutil" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: combinediff ") + os.Exit(1) + } + + patch1, err := os.Open(os.Args[1]) + if err != nil { + fmt.Println(err) + } + patch2, err := os.Open(os.Args[2]) + if err != nil { + fmt.Println(err) + } + + files1, _, err := gitdiff.Parse(patch1) + if err != nil { + fmt.Println(err) + } + + files2, _, err := gitdiff.Parse(patch2) + if err != nil { + fmt.Println(err) + } + + combined := patchutil.CombineDiff(files1, files2) + fmt.Println(combined) +} diff --git a/cmd/interdiff/main.go b/cmd/interdiff/main.go new file mode 100644 index 0000000..6ed26ce --- /dev/null +++ b/cmd/interdiff/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "tangled.sh/tangled.sh/core/patchutil" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: interdiff ") + os.Exit(1) + } + + patch1, err := os.Open(os.Args[1]) + if err != nil { + fmt.Println(err) + } + patch2, err := os.Open(os.Args[2]) + if err != nil { + fmt.Println(err) + } + + files1, _, err := gitdiff.Parse(patch1) + if err != nil { + fmt.Println(err) + } + + files2, _, err := gitdiff.Parse(patch2) + if err != nil { + fmt.Println(err) + } + + interDiffResult := patchutil.Interdiff(files1, files2) + fmt.Println(interDiffResult) +} diff --git a/go.mod b/go.mod index 281a0b5..76b7143 100644 --- a/go.mod +++ b/go.mod @@ -106,9 +106,9 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 20e3ac7..7ac12ce 100644 --- a/go.sum +++ b/go.sum @@ -303,8 +303,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -327,8 +327,8 @@ golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -357,23 +357,23 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/patchutil/combinediff.go b/patchutil/combinediff.go new file mode 100644 index 0000000..091ec88 --- /dev/null +++ b/patchutil/combinediff.go @@ -0,0 +1,168 @@ +package patchutil + +import ( + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" +) + +// original1 -> patch1 -> rev1 +// original2 -> patch2 -> rev2 +// +// original2 must be equal to rev1, so we can merge them to get maximal context +// +// finally, +// rev2' <- apply(patch2, merged) +// combineddiff <- diff(rev2', original1) +func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) { + fileName := bestName(file1) + + o1 := CreatePreImage(file1) + r1 := CreatePostImage(file1) + o2 := CreatePreImage(file2) + + merged, err := r1.Merge(&o2) + if err != nil { + return nil, err + } + + r2Prime, err := merged.Apply(file2) + if err != nil { + return nil, err + } + + // produce combined diff + diff, err := Unified(o1.String(), fileName, r2Prime, fileName) + if err != nil { + return nil, err + } + + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) + + if len(parsed) != 1 { + // no diff? the second commit reverted the changes from the first + return nil, nil + } + + return parsed[0], nil +} + +// use empty lines for lines we are unaware of +// +// this raises an error only if the two patches were invalid or non-contiguous +func mergeLines(old, new string) (string, error) { + var i, j int + + // TODO: use strings.Lines + linesOld := strings.Split(old, "\n") + linesNew := strings.Split(new, "\n") + + result := []string{} + + for i < len(linesOld) || j < len(linesNew) { + if i >= len(linesOld) { + // rest of the file is populated from `new` + result = append(result, linesNew[j]) + j++ + continue + } + + if j >= len(linesNew) { + // rest of the file is populated from `old` + result = append(result, linesOld[i]) + i++ + continue + } + + oldLine := linesOld[i] + newLine := linesNew[j] + + if oldLine != newLine && (oldLine != "" && newLine != "") { + // context mismatch + return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine) + } + + if oldLine == newLine { + result = append(result, oldLine) + } else if oldLine == "" { + result = append(result, newLine) + } else if newLine == "" { + result = append(result, oldLine) + } + i++ + j++ + } + + return strings.Join(result, "\n"), nil +} + +func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File { + fileToIdx1 := make(map[string]int) + fileToIdx2 := make(map[string]int) + visited := make(map[string]struct{}) + var result []*gitdiff.File + + for idx, f := range patch1 { + fileToIdx1[bestName(f)] = idx + } + + for idx, f := range patch2 { + fileToIdx2[bestName(f)] = idx + } + + for _, f1 := range patch1 { + fileName := bestName(f1) + if idx, ok := fileToIdx2[fileName]; ok { + f2 := patch2[idx] + + // we have f1 and f2, combine them + combined, err := combineFiles(f1, f2) + if err != nil { + fmt.Println(err) + } + + result = append(result, combined) + } else { + // only in patch1; add as-is + result = append(result, f1) + } + + visited[fileName] = struct{}{} + } + + // for all files in patch2 that remain unvisited; we can just add them into the output + for _, f2 := range patch2 { + fileName := bestName(f2) + if _, ok := visited[fileName]; ok { + continue + } + + result = append(result, f2) + } + + return result +} + +// pairwise combination from first to last patch +func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File { + if len(patches) == 0 { + return nil + } + + if len(patches) == 1 { + return patches[0] + } + + combined := combineTwo(patches[0], patches[1]) + + newPatches := [][]*gitdiff.File{} + newPatches = append(newPatches, combined) + for i, p := range patches { + if i >= 2 { + newPatches = append(newPatches, p) + } + } + + return CombineDiff(newPatches...) +} diff --git a/patchutil/image.go b/patchutil/image.go new file mode 100644 index 0000000..1a12d22 --- /dev/null +++ b/patchutil/image.go @@ -0,0 +1,178 @@ +package patchutil + +import ( + "bytes" + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" +) + +type Line struct { + LineNumber int64 + Content string + IsUnknown bool +} + +func NewLineAt(lineNumber int64, content string) Line { + return Line{ + LineNumber: lineNumber, + Content: content, + IsUnknown: false, + } +} + +type Image struct { + File string + Data []*Line +} + +func (r *Image) String() string { + var i, j int64 + var b strings.Builder + for { + i += 1 + + if int(j) >= (len(r.Data)) { + break + } + + if r.Data[j].LineNumber == i { + // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) + b.WriteString(r.Data[j].Content) + j += 1 + } else { + //b.WriteString(fmt.Sprintf("%d:\n", i)) + b.WriteString("\n") + } + } + + return b.String() +} + +func (r *Image) AddLine(line *Line) { + r.Data = append(r.Data, line) +} + +// rebuild the original file from a patch +func CreatePreImage(file *gitdiff.File) Image { + rf := Image{ + File: bestName(file), + } + + for _, fragment := range file.TextFragments { + position := fragment.OldPosition + for _, line := range fragment.Lines { + switch line.Op { + case gitdiff.OpContext: + rl := NewLineAt(position, line.Line) + rf.Data = append(rf.Data, &rl) + position += 1 + case gitdiff.OpDelete: + rl := NewLineAt(position, line.Line) + rf.Data = append(rf.Data, &rl) + position += 1 + case gitdiff.OpAdd: + // do nothing here + } + } + } + + return rf +} + +// rebuild the revised file from a patch +func CreatePostImage(file *gitdiff.File) Image { + rf := Image{ + File: bestName(file), + } + + for _, fragment := range file.TextFragments { + position := fragment.NewPosition + for _, line := range fragment.Lines { + switch line.Op { + case gitdiff.OpContext: + rl := NewLineAt(position, line.Line) + rf.Data = append(rf.Data, &rl) + position += 1 + case gitdiff.OpAdd: + rl := NewLineAt(position, line.Line) + rf.Data = append(rf.Data, &rl) + position += 1 + case gitdiff.OpDelete: + // do nothing here + } + } + } + + return rf +} + +type MergeError struct { + msg string + mismatchingLine int64 +} + +func (m MergeError) Error() string { + return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) +} + +// best effort merging of two reconstructed files +func (this *Image) Merge(other *Image) (*Image, error) { + mergedFile := Image{} + + var i, j int64 + + for int(i) < len(this.Data) || int(j) < len(other.Data) { + if int(i) >= len(this.Data) { + // first file is done; the rest of the lines from file 2 can go in + mergedFile.AddLine(other.Data[j]) + j++ + continue + } + + if int(j) >= len(other.Data) { + // first file is done; the rest of the lines from file 2 can go in + mergedFile.AddLine(this.Data[i]) + i++ + continue + } + + line1 := this.Data[i] + line2 := other.Data[j] + + if line1.LineNumber == line2.LineNumber { + if line1.Content != line2.Content { + return nil, MergeError{ + msg: "mismatching lines, this patch might have undergone rebase", + mismatchingLine: line1.LineNumber, + } + } else { + mergedFile.AddLine(line1) + } + i++ + j++ + } else if line1.LineNumber < line2.LineNumber { + mergedFile.AddLine(line1) + i++ + } else { + mergedFile.AddLine(line2) + j++ + } + } + + return &mergedFile, nil +} + +func (r *Image) Apply(patch *gitdiff.File) (string, error) { + original := r.String() + var buffer bytes.Buffer + reader := strings.NewReader(original) + + err := gitdiff.Apply(&buffer, reader, patch) + if err != nil { + return "", err + } + + return buffer.String(), nil +} diff --git a/patchutil/interdiff.go b/patchutil/interdiff.go new file mode 100644 index 0000000..6f4072b --- /dev/null +++ b/patchutil/interdiff.go @@ -0,0 +1,236 @@ +package patchutil + +import ( + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" +) + +type InterdiffResult struct { + Files []*InterdiffFile +} + +func (i *InterdiffResult) String() string { + var b strings.Builder + for _, f := range i.Files { + b.WriteString(f.String()) + b.WriteString("\n") + } + + return b.String() +} + +type InterdiffFile struct { + *gitdiff.File + Name string + Status InterdiffFileStatus +} + +func (s *InterdiffFile) String() string { + var b strings.Builder + b.WriteString(s.Status.String()) + b.WriteString(" ") + + if s.File != nil { + b.WriteString(bestName(s.File)) + b.WriteString("\n") + b.WriteString(s.File.String()) + } + + return b.String() +} + +type InterdiffFileStatus struct { + StatusKind StatusKind + Error error +} + +func (s *InterdiffFileStatus) String() string { + kind := s.StatusKind.String() + if s.Error != nil { + return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) + } else { + return kind + } +} + +func (s *InterdiffFileStatus) IsOk() bool { + return s.StatusKind == StatusOk +} + +func (s *InterdiffFileStatus) IsUnchanged() bool { + return s.StatusKind == StatusUnchanged +} + +func (s *InterdiffFileStatus) IsOnlyInOne() bool { + return s.StatusKind == StatusOnlyInOne +} + +func (s *InterdiffFileStatus) IsOnlyInTwo() bool { + return s.StatusKind == StatusOnlyInTwo +} + +func (s *InterdiffFileStatus) IsRebased() bool { + return s.StatusKind == StatusRebased +} + +func (s *InterdiffFileStatus) IsError() bool { + return s.StatusKind == StatusError +} + +type StatusKind int + +func (k StatusKind) String() string { + switch k { + case StatusOnlyInOne: + return "only in one" + case StatusOnlyInTwo: + return "only in two" + case StatusUnchanged: + return "unchanged" + case StatusRebased: + return "rebased" + case StatusError: + return "error" + default: + return "changed" + } +} + +const ( + StatusOk StatusKind = iota + StatusOnlyInOne + StatusOnlyInTwo + StatusUnchanged + StatusRebased + StatusError +) + +func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { + re1 := CreatePreImage(f1) + re2 := CreatePreImage(f2) + + interdiffFile := InterdiffFile{ + Name: bestName(f1), + } + + merged, err := re1.Merge(&re2) + if err != nil { + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusRebased, + Error: err, + } + return &interdiffFile + } + + rev1, err := merged.Apply(f1) + if err != nil { + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusError, + Error: err, + } + return &interdiffFile + } + + rev2, err := merged.Apply(f2) + if err != nil { + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusError, + Error: err, + } + return &interdiffFile + } + + diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) + if err != nil { + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusError, + Error: err, + } + return &interdiffFile + } + + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) + if err != nil { + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusError, + Error: err, + } + return &interdiffFile + } + + if len(parsed) != 1 { + // files are identical? + interdiffFile.Status = InterdiffFileStatus{ + StatusKind: StatusUnchanged, + } + return &interdiffFile + } + + if interdiffFile.Status.StatusKind == StatusOk { + interdiffFile.File = parsed[0] + } + + return &interdiffFile +} + +func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { + fileToIdx1 := make(map[string]int) + fileToIdx2 := make(map[string]int) + visited := make(map[string]struct{}) + var result InterdiffResult + + for idx, f := range patch1 { + fileToIdx1[bestName(f)] = idx + } + + for idx, f := range patch2 { + fileToIdx2[bestName(f)] = idx + } + + for _, f1 := range patch1 { + var interdiffFile *InterdiffFile + + fileName := bestName(f1) + if idx, ok := fileToIdx2[fileName]; ok { + f2 := patch2[idx] + + // we have f1 and f2, calculate interdiff + interdiffFile = interdiffFiles(f1, f2) + } else { + // only in patch 1, this change would have to be "inverted" to dissapear + // from patch 2, so we reverseDiff(f1) + reverseDiff(f1) + + interdiffFile = &InterdiffFile{ + File: f1, + Name: fileName, + Status: InterdiffFileStatus{ + StatusKind: StatusOnlyInOne, + }, + } + } + + result.Files = append(result.Files, interdiffFile) + visited[fileName] = struct{}{} + } + + // for all files in patch2 that remain unvisited; we can just add them into the output + for _, f2 := range patch2 { + fileName := bestName(f2) + if _, ok := visited[fileName]; ok { + continue + } + + result.Files = append(result.Files, &InterdiffFile{ + File: f2, + Name: fileName, + Status: InterdiffFileStatus{ + StatusKind: StatusOnlyInTwo, + }, + }) + } + + return &result +} diff --git a/patchutil/patchutil.go b/patchutil/patchutil.go index eb9281f..8132e19 100644 --- a/patchutil/patchutil.go +++ b/patchutil/patchutil.go @@ -2,6 +2,8 @@ package patchutil import ( "fmt" + "os" + "os/exec" "regexp" "strings" @@ -125,3 +127,70 @@ func splitFormatPatch(patchText string) []string { } return patches } + +func bestName(file *gitdiff.File) string { + if file.IsDelete { + return file.OldName + } else { + return file.NewName + } +} + +// in-place reverse of a diff +func reverseDiff(file *gitdiff.File) { + file.OldName, file.NewName = file.NewName, file.OldName + file.OldMode, file.NewMode = file.NewMode, file.OldMode + file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment + + for _, fragment := range file.TextFragments { + // swap postions + fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition + fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines + fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded + + for i := range fragment.Lines { + switch fragment.Lines[i].Op { + case gitdiff.OpAdd: + fragment.Lines[i].Op = gitdiff.OpDelete + case gitdiff.OpDelete: + fragment.Lines[i].Op = gitdiff.OpAdd + default: + // do nothing + } + } + } +} + +func Unified(oldText, oldFile, newText, newFile string) (string, error) { + oldTemp, err := os.CreateTemp("", "old_*") + if err != nil { + return "", fmt.Errorf("failed to create temp file for oldText: %w", err) + } + defer os.Remove(oldTemp.Name()) + if _, err := oldTemp.WriteString(oldText); err != nil { + return "", fmt.Errorf("failed to write to old temp file: %w", err) + } + oldTemp.Close() + + newTemp, err := os.CreateTemp("", "new_*") + if err != nil { + return "", fmt.Errorf("failed to create temp file for newText: %w", err) + } + defer os.Remove(newTemp.Name()) + if _, err := newTemp.WriteString(newText); err != nil { + return "", fmt.Errorf("failed to write to new temp file: %w", err) + } + newTemp.Close() + + cmd := exec.Command("diff", "-U", "9999", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) + output, err := cmd.CombinedOutput() + + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return string(output), nil + } + if err != nil { + return "", fmt.Errorf("diff command failed: %w", err) + } + + return string(output), nil +} -- 2.48.1