diff --git a/appview/db/pulls.go b/appview/db/pulls.go
--- a/appview/db/pulls.go
+++ b/appview/db/pulls.go
@@ -150,10 +150,35 @@ }
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
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -16,17 +16,19 @@ "path/filepath"
"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
@@ -705,6 +707,20 @@
// this name is a mouthful
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 {
diff --git a/appview/pages/templates/repo/fragments/interdiff.html b/appview/pages/templates/repo/fragments/interdiff.html
new file mode 100644
--- /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 }}
+
+
+
+ {{ $last := sub (len $diff) 1 }}
+ {{ range $idx, $hunk := $diff }}
+ {{ with $hunk }}
+
+
+
+
+
+
+
+
+
+ {{ 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 "+" -}}
+
+
{{$newStart}}
+
+
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- $newStart = add64 $newStart 1 -}}
+ {{- end -}}
+ {{- if eq .Op.String "-" -}}
+
+
+
{{$oldStart}}
+
{{ .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
--- /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" }}
+
+
+
+ {{ 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
--- a/appview/pages/templates/repo/pulls/pull.html
+++ b/appview/pages/templates/repo/pulls/pull.html
@@ -51,13 +51,18 @@ {{ len .Comments }} comment{{$s}}
- {{ if $.Pull.IsPatchBased }}
-
{{ i "file-diff" "w-4 h-4" }} view patch
+ {{ if not (eq .RoundNumber 0) }}
+
+ {{ i "file-diff" "w-4 h-4" }} interdiff
+
+
{{ end }}
diff --git a/appview/state/pull.go b/appview/state/pull.go
--- a/appview/state/pull.go
+++ b/appview/state/pull.go
@@ -12,7 +12,6 @@ "net/url"
"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 @@
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
@@ -305,6 +305,74 @@ Submission: pull.Submissions[roundIdInt],
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
})
+}
+
+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) {
diff --git a/appview/state/router.go b/appview/state/router.go
--- a/appview/state/router.go
+++ b/appview/state/router.go
@@ -109,6 +109,7 @@ r.Get("/", s.RepoSinglePull)
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
--- /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
--- /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
--- a/go.mod
+++ b/go.mod
@@ -106,9 +106,9 @@ go.opentelemetry.io/otel/trace v1.21.0 // indirect
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
--- a/go.sum
+++ b/go.sum
@@ -303,8 +303,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
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
--- /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
--- /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
--- /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
--- 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 @@ patches[i] = strings.TrimSpace(patchText[startPos:endPos])
}
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
+}