Experimental browser for the Atmosphere
{ "uri": "at://did:plc:hwevmowznbiukdf6uk5dwrrq/sh.tangled.repo.pull/3lne4om57xe22", "cid": "bafyreiabnjs7ffxony4a5jjpc2ec3bfxboxalqqpuby5aux2bvu4h6niya", "value": { "$type": "sh.tangled.repo.pull", "patch": "From 889c121f7eb8a21bb0828ae524922641c67d473d Mon Sep 17 00:00:00 2001\nFrom: \"oppili.bsky.social\" <nerdy@peppe.rs>\nDate: Mon, 21 Apr 2025 18:49:58 +0000\nSubject: [PATCH] appview: implement interdiff\n\ntakes a lot of inspiration from patchutils' interdiff algorithm. unlike gerrit; rebase detection is very much a work in progress.\n---\n appview/db/pulls.go | 29 ++-\n appview/pages/pages.go | 26 +-\n .../templates/repo/fragments/interdiff.html | 148 +++++++++++\n .../pages/templates/repo/pulls/interdiff.html | 25 ++\n appview/pages/templates/repo/pulls/pull.html | 9 +-\n appview/state/pull.go | 70 +++++-\n appview/state/router.go | 1 +\n cmd/combinediff/main.go | 38 +++\n cmd/interdiff/main.go | 38 +++\n go.mod | 6 +-\n go.sum | 20 +-\n patchutil/combinediff.go | 168 +++++++++++++\n patchutil/image.go | 178 +++++++++++++\n patchutil/interdiff.go | 236 ++++++++++++++++++\n patchutil/patchutil.go | 69 +++++\n 15 files changed, 1038 insertions(+), 23 deletions(-)\n create mode 100644 appview/pages/templates/repo/fragments/interdiff.html\n create mode 100644 appview/pages/templates/repo/pulls/interdiff.html\n create mode 100644 cmd/combinediff/main.go\n create mode 100644 cmd/interdiff/main.go\n create mode 100644 patchutil/combinediff.go\n create mode 100644 patchutil/image.go\n create mode 100644 patchutil/interdiff.go\n\ndiff --git a/appview/db/pulls.go b/appview/db/pulls.go\nindex 6379fa4..ac35efa 100644\n--- a/appview/db/pulls.go\n+++ b/appview/db/pulls.go\n@@ -150,10 +150,35 @@ func (p *Pull) IsForkBased() bool {\n \treturn false\n }\n \n-func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {\n+func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {\n \tpatch := s.Patch\n \n-\tdiffs, _, err := gitdiff.Parse(strings.NewReader(patch))\n+\t// if format-patch; then extract each patch\n+\tvar diffs []*gitdiff.File\n+\tif patchutil.IsFormatPatch(patch) {\n+\t\tpatches, err := patchutil.ExtractPatches(patch)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\tvar ps [][]*gitdiff.File\n+\t\tfor _, p := range patches {\n+\t\t\tps = append(ps, p.Files)\n+\t\t}\n+\n+\t\tdiffs = patchutil.CombineDiff(ps...)\n+\t} else {\n+\t\td, _, err := gitdiff.Parse(strings.NewReader(patch))\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\tdiffs = d\n+\t}\n+\n+\treturn diffs, nil\n+}\n+\n+func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {\n+\tdiffs, err := s.AsDiff(targetBranch)\n \tif err != nil {\n \t\tlog.Println(err)\n \t}\ndiff --git a/appview/pages/pages.go b/appview/pages/pages.go\nindex f3e3d9d..93e8117 100644\n--- a/appview/pages/pages.go\n+++ b/appview/pages/pages.go\n@@ -16,17 +16,19 @@ import (\n \t\"slices\"\n \t\"strings\"\n \n+\t\"tangled.sh/tangled.sh/core/appview/auth\"\n+\t\"tangled.sh/tangled.sh/core/appview/db\"\n+\t\"tangled.sh/tangled.sh/core/appview/pages/markup\"\n+\t\"tangled.sh/tangled.sh/core/appview/state/userutil\"\n+\t\"tangled.sh/tangled.sh/core/patchutil\"\n+\t\"tangled.sh/tangled.sh/core/types\"\n+\n \t\"github.com/alecthomas/chroma/v2\"\n \tchromahtml \"github.com/alecthomas/chroma/v2/formatters/html\"\n \t\"github.com/alecthomas/chroma/v2/lexers\"\n \t\"github.com/alecthomas/chroma/v2/styles\"\n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n \t\"github.com/microcosm-cc/bluemonday\"\n-\t\"tangled.sh/tangled.sh/core/appview/auth\"\n-\t\"tangled.sh/tangled.sh/core/appview/db\"\n-\t\"tangled.sh/tangled.sh/core/appview/pages/markup\"\n-\t\"tangled.sh/tangled.sh/core/appview/state/userutil\"\n-\t\"tangled.sh/tangled.sh/core/types\"\n )\n \n //go:embed templates/* static\n@@ -707,6 +709,20 @@ func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error\n \treturn p.execute(\"repo/pulls/patch\", w, params)\n }\n \n+type RepoPullInterdiffParams struct {\n+\tLoggedInUser *auth.User\n+\tDidHandleMap map[string]string\n+\tRepoInfo RepoInfo\n+\tPull *db.Pull\n+\tRound int\n+\tInterdiff *patchutil.InterdiffResult\n+}\n+\n+// this name is a mouthful\n+func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {\n+\treturn p.execute(\"repo/pulls/interdiff\", w, params)\n+}\n+\n type PullPatchUploadParams struct {\n \tRepoInfo RepoInfo\n }\ndiff --git a/appview/pages/templates/repo/fragments/interdiff.html b/appview/pages/templates/repo/fragments/interdiff.html\nnew file mode 100644\nindex 0000000..f9fd6e5\n--- /dev/null\n+++ b/appview/pages/templates/repo/fragments/interdiff.html\n@@ -0,0 +1,148 @@\n+{{ define \"repo/fragments/interdiff\" }}\n+{{ $repo := index . 0 }}\n+{{ $x := index . 1 }}\n+{{ $diff := $x.Files }}\n+\n+ <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\">\n+ <div class=\"diff-stat\">\n+ <div class=\"flex gap-2 items-center\">\n+ <strong class=\"text-sm uppercase dark:text-gray-200\">files</strong>\n+ </div>\n+ <div class=\"overflow-x-auto\">\n+ <ul class=\"dark:text-gray-200\">\n+ {{ range $diff }}\n+ <li><a href=\"#file-{{ .Name }}\" class=\"dark:hover:text-gray-300\">{{ .Name }}</a></li>\n+ {{ end }}\n+ </ul>\n+ </div>\n+ </div>\n+ </section>\n+\n+ {{ $last := sub (len $diff) 1 }}\n+ {{ range $idx, $hunk := $diff }}\n+ {{ with $hunk }}\n+ <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\">\n+ <div id=\"file-{{ .Name }}\">\n+ <div id=\"diff-file\">\n+ <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>\n+ <summary class=\"list-none cursor-pointer sticky top-0\">\n+ <div id=\"diff-file-header\" class=\"rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between\">\n+ <div id=\"left-side-items\" class=\"p-2 flex gap-2 items-center overflow-x-auto\">\n+ <div class=\"flex gap-1 items-center\" style=\"direction: ltr;\">\n+ {{ $markerstyle := \"diff-type p-1 mr-1 font-mono text-sm rounded select-none\" }}\n+ {{ if .Status.IsOk }}\n+ <span class=\"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}\">CHANGED</span>\n+ {{ else if .Status.IsUnchanged }}\n+ <span class=\"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}\">UNCHANGED</span>\n+ {{ else if .Status.IsOnlyInOne }}\n+ <span class=\"bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}\">REVERTED</span>\n+ {{ else if .Status.IsOnlyInTwo }}\n+ <span class=\"bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}\">NEW</span>\n+ {{ else if .Status.IsRebased }}\n+ <span class=\"bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}\">REBASED</span>\n+ {{ else }}\n+ <span class=\"bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}\">ERROR</span>\n+ {{ end }}\n+ </div>\n+\n+ <div class=\"flex gap-2 items-center overflow-x-auto\" style=\"direction: rtl;\">\n+ <a class=\"dark:text-white whitespace-nowrap overflow-x-auto\" href=\"\">\n+ {{ .Name }}\n+ </a>\n+ </div>\n+ </div>\n+\n+ {{ $iconstyle := \"p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded\" }}\n+ <div id=\"right-side-items\" class=\"p-2 flex items-center\">\n+ <a title=\"top of file\" href=\"#file-{{ .Name }}\" class=\"{{ $iconstyle }}\">{{ i \"arrow-up-to-line\" \"w-4 h-4\" }}</a>\n+ {{ if gt $idx 0 }}\n+ {{ $prev := index $diff (sub $idx 1) }}\n+ <a title=\"previous file\" href=\"#file-{{ $prev.Name }}\" class=\"{{ $iconstyle }}\">{{ i \"arrow-up\" \"w-4 h-4\" }}</a>\n+ {{ end }}\n+\n+ {{ if lt $idx $last }}\n+ {{ $next := index $diff (add $idx 1) }}\n+ <a title=\"next file\" href=\"#file-{{ $next.Name }}\" class=\"{{ $iconstyle }}\">{{ i \"arrow-down\" \"w-4 h-4\" }}</a>\n+ {{ end }}\n+ </div>\n+\n+ </div>\n+ </summary>\n+\n+ <div class=\"transition-all duration-700 ease-in-out\">\n+ {{ if .Status.IsUnchanged }}\n+ <p class=\"text-center text-gray-400 dark:text-gray-500 p-4\">\n+ This file has not been changed.\n+ </p>\n+ {{ else if .Status.IsRebased }}\n+ <p class=\"text-center text-gray-400 dark:text-gray-500 p-4\">\n+ This patch was likely rebased, as context lines do not match.\n+ </p>\n+ {{ else if .Status.IsError }}\n+ <p class=\"text-center text-gray-400 dark:text-gray-500 p-4\">\n+ Failed to calculate interdiff for this file.\n+ </p>\n+ {{ else }}\n+ {{ $name := .Name }}\n+ <pre class=\"overflow-x-auto\"><div class=\"overflow-x-auto\"><div class=\"min-w-full inline-block\">{{- range .TextFragments -}}<div class=\"bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center\">···</div>\n+ {{- $oldStart := .OldPosition -}}\n+ {{- $newStart := .NewPosition -}}\n+ {{- $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 \" -}}\n+ {{- $linkStyle := \"text-gray-400 dark:text-gray-500 hover:underline\" -}}\n+ {{- $lineNrSepStyle1 := \"\" -}}\n+ {{- $lineNrSepStyle2 := \"pr-2\" -}}\n+ {{- range .Lines -}}\n+ {{- if eq .Op.String \"+\" -}}\n+ <div class=\"bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center\">\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle1}}\"><span aria-hidden=\"true\" class=\"invisible\">{{$newStart}}</span></div>\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle2}}\" id=\"{{$name}}-N{{$newStart}}\"><a class=\"{{$linkStyle}}\" href=\"#{{$name}}-N{{$newStart}}\">{{ $newStart }}</a></div>\n+ <div class=\"w-5 flex-shrink-0 select-none text-center\">{{ .Op.String }}</div>\n+ <div class=\"px-2\">{{ .Line }}</div>\n+ </div>\n+ {{- $newStart = add64 $newStart 1 -}}\n+ {{- end -}}\n+ {{- if eq .Op.String \"-\" -}}\n+ <div class=\"bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center\">\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle1}}\" id=\"{{$name}}-O{{$oldStart}}\"><a class=\"{{$linkStyle}}\" href=\"#{{$name}}-O{{$oldStart}}\">{{ $oldStart }}</a></div>\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle2}}\"><span aria-hidden=\"true\" class=\"invisible\">{{$oldStart}}</span></div>\n+ <div class=\"w-5 flex-shrink-0 select-none text-center\">{{ .Op.String }}</div>\n+ <div class=\"px-2\">{{ .Line }}</div>\n+ </div>\n+ {{- $oldStart = add64 $oldStart 1 -}}\n+ {{- end -}}\n+ {{- if eq .Op.String \" \" -}}\n+ <div class=\"bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center\">\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle1}}\" id=\"{{$name}}-O{{$oldStart}}\"><a class=\"{{$linkStyle}}\" href=\"#{{$name}}-O{{$oldStart}}\">{{ $oldStart }}</a></div>\n+ <div class=\"{{$lineNrStyle}} {{$lineNrSepStyle2}}\" id=\"{{$name}}-N{{$newStart}}\"><a class=\"{{$linkStyle}}\" href=\"#{{$name}}-N{{$newStart}}\">{{ $newStart }}</a></div>\n+ <div class=\"w-5 flex-shrink-0 select-none text-center\">{{ .Op.String }}</div>\n+ <div class=\"px-2\">{{ .Line }}</div>\n+ </div>\n+ {{- $newStart = add64 $newStart 1 -}}\n+ {{- $oldStart = add64 $oldStart 1 -}}\n+ {{- end -}}\n+ {{- end -}}\n+ {{- end -}}</div></div></pre>\n+ {{- end -}}\n+ </div>\n+\n+ </details>\n+\n+ </div>\n+ </div>\n+ </section>\n+ {{ end }}\n+ {{ end }}\n+{{ end }}\n+\n+{{ define \"statPill\" }}\n+ <div class=\"flex items-center font-mono text-sm\">\n+ {{ if and .Insertions .Deletions }}\n+ <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>\n+ <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>\n+ {{ else if .Insertions }}\n+ <span class=\"rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400\">+{{ .Insertions }}</span>\n+ {{ else if .Deletions }}\n+ <span class=\"rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400\">-{{ .Deletions }}</span>\n+ {{ end }}\n+ </div>\n+{{ end }}\ndiff --git a/appview/pages/templates/repo/pulls/interdiff.html b/appview/pages/templates/repo/pulls/interdiff.html\nnew file mode 100644\nindex 0000000..c333be1\n--- /dev/null\n+++ b/appview/pages/templates/repo/pulls/interdiff.html\n@@ -0,0 +1,25 @@\n+{{ define \"title\" }}\n+ interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}\n+{{ end }}\n+\n+{{ define \"content\" }}\n+ <section class=\"rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white\">\n+ <header class=\"pb-2\">\n+ <div class=\"flex gap-3 items-center mb-3\">\n+ <a href=\"/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/\" class=\"flex items-center gap-2 font-medium\">\n+ {{ i \"arrow-left\" \"w-5 h-5\" }}\n+ back\n+ </a>\n+ <span class=\"select-none before:content-['\\00B7']\"></span>\n+ interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}\n+ </div>\n+ <div class=\"border-t border-gray-200 dark:border-gray-700 my-2\"></div>\n+ {{ template \"repo/pulls/fragments/pullHeader\" . }}\n+ </header>\n+ </section>\n+\n+ <section>\n+ {{ template \"repo/fragments/interdiff\" (list .RepoInfo.FullName .Interdiff) }}\n+ </section>\n+{{ end }}\n+\ndiff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html\nindex ab67cde..d692e3b 100644\n--- a/appview/pages/templates/repo/pulls/pull.html\n+++ b/appview/pages/templates/repo/pulls/pull.html\n@@ -51,13 +51,18 @@\n </span>\n </div>\n \n- {{ if $.Pull.IsPatchBased }}\n- <!-- view patch -->\n <a class=\"btn flex items-center gap-2 no-underline hover:no-underline p-2\"\n hx-boost=\"true\"\n href=\"/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}\">\n {{ i \"file-diff\" \"w-4 h-4\" }} <span class=\"hidden md:inline\">view patch</span>\n </a>\n+ {{ if not (eq .RoundNumber 0) }}\n+ <a class=\"btn flex items-center gap-2 no-underline hover:no-underline p-2\"\n+ hx-boost=\"true\"\n+ href=\"/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff\">\n+ {{ i \"file-diff\" \"w-4 h-4\" }} <span class=\"hidden md:inline\">interdiff</span>\n+ </a>\n+ <span id=\"interdiff-error-{{.RoundNumber}}\"></span>\n {{ end }}\n </div>\n </summary>\ndiff --git a/appview/state/pull.go b/appview/state/pull.go\nindex bb9531e..fc04a99 100644\n--- a/appview/state/pull.go\n+++ b/appview/state/pull.go\n@@ -12,7 +12,6 @@ import (\n \t\"strconv\"\n \t\"time\"\n \n-\t\"github.com/go-chi/chi/v5\"\n \t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview/auth\"\n \t\"tangled.sh/tangled.sh/core/appview/db\"\n@@ -23,6 +22,7 @@ import (\n \tcomatproto \"github.com/bluesky-social/indigo/api/atproto\"\n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n \tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n+\t\"github.com/go-chi/chi/v5\"\n )\n \n // htmx fragment\n@@ -307,6 +307,74 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {\n \n }\n \n+func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {\n+\tuser := s.auth.GetUser(r)\n+\n+\tf, err := fullyResolvedRepo(r)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get repo and knot\", err)\n+\t\treturn\n+\t}\n+\n+\tpull, ok := r.Context().Value(\"pull\").(*db.Pull)\n+\tif !ok {\n+\t\tlog.Println(\"failed to get pull\")\n+\t\ts.pages.Notice(w, \"pull-error\", \"Failed to get pull.\")\n+\t\treturn\n+\t}\n+\n+\troundId := chi.URLParam(r, \"round\")\n+\troundIdInt, err := strconv.Atoi(roundId)\n+\tif err != nil || roundIdInt >= len(pull.Submissions) {\n+\t\thttp.Error(w, \"bad round id\", http.StatusBadRequest)\n+\t\tlog.Println(\"failed to parse round id\", err)\n+\t\treturn\n+\t}\n+\n+\tif roundIdInt == 0 {\n+\t\thttp.Error(w, \"bad round id\", http.StatusBadRequest)\n+\t\tlog.Println(\"cannot interdiff initial submission\")\n+\t\treturn\n+\t}\n+\n+\tidentsToResolve := []string{pull.OwnerDid}\n+\tresolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)\n+\tdidHandleMap := make(map[string]string)\n+\tfor _, identity := range resolvedIds {\n+\t\tif !identity.Handle.IsInvalidHandle() {\n+\t\t\tdidHandleMap[identity.DID.String()] = fmt.Sprintf(\"@%s\", identity.Handle.String())\n+\t\t} else {\n+\t\t\tdidHandleMap[identity.DID.String()] = identity.DID.String()\n+\t\t}\n+\t}\n+\n+\tcurrentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to interdiff; current patch malformed\")\n+\t\ts.pages.Notice(w, fmt.Sprintf(\"interdiff-error-%d\", roundIdInt), \"Failed to calculate interdiff; current patch is invalid.\")\n+\t\treturn\n+\t}\n+\n+\tpreviousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to interdiff; previous patch malformed\")\n+\t\ts.pages.Notice(w, fmt.Sprintf(\"interdiff-error-%d\", roundIdInt), \"Failed to calculate interdiff; previous patch is invalid.\")\n+\t\treturn\n+\t}\n+\n+\tinterdiff := patchutil.Interdiff(previousPatch, currentPatch)\n+\n+\ts.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{\n+\t\tLoggedInUser: s.auth.GetUser(r),\n+\t\tRepoInfo: f.RepoInfo(s, user),\n+\t\tPull: pull,\n+\t\tRound: roundIdInt,\n+\t\tDidHandleMap: didHandleMap,\n+\t\tInterdiff: interdiff,\n+\t})\n+\treturn\n+}\n+\n func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {\n \tpull, ok := r.Context().Value(\"pull\").(*db.Pull)\n \tif !ok {\ndiff --git a/appview/state/router.go b/appview/state/router.go\nindex 49ed7f3..92e87b8 100644\n--- a/appview/state/router.go\n+++ b/appview/state/router.go\n@@ -109,6 +109,7 @@ func (s *State) UserRouter() http.Handler {\n \n \t\t\t\t\tr.Route(\"/round/{round}\", func(r chi.Router) {\n \t\t\t\t\t\tr.Get(\"/\", s.RepoPullPatch)\n+\t\t\t\t\t\tr.Get(\"/interdiff\", s.RepoPullInterdiff)\n \t\t\t\t\t\tr.Get(\"/actions\", s.PullActions)\n \t\t\t\t\t\tr.With(AuthMiddleware(s)).Route(\"/comment\", func(r chi.Router) {\n \t\t\t\t\t\t\tr.Get(\"/\", s.PullComment)\ndiff --git a/cmd/combinediff/main.go b/cmd/combinediff/main.go\nnew file mode 100644\nindex 0000000..6f5d7ab\n--- /dev/null\n+++ b/cmd/combinediff/main.go\n@@ -0,0 +1,38 @@\n+package main\n+\n+import (\n+\t\"fmt\"\n+\t\"os\"\n+\n+\t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n+\t\"tangled.sh/tangled.sh/core/patchutil\"\n+)\n+\n+func main() {\n+\tif len(os.Args) != 3 {\n+\t\tfmt.Println(\"Usage: combinediff <patch1> <patch2>\")\n+\t\tos.Exit(1)\n+\t}\n+\n+\tpatch1, err := os.Open(os.Args[1])\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\tpatch2, err := os.Open(os.Args[2])\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tfiles1, _, err := gitdiff.Parse(patch1)\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tfiles2, _, err := gitdiff.Parse(patch2)\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tcombined := patchutil.CombineDiff(files1, files2)\n+\tfmt.Println(combined)\n+}\ndiff --git a/cmd/interdiff/main.go b/cmd/interdiff/main.go\nnew file mode 100644\nindex 0000000..6ed26ce\n--- /dev/null\n+++ b/cmd/interdiff/main.go\n@@ -0,0 +1,38 @@\n+package main\n+\n+import (\n+\t\"fmt\"\n+\t\"os\"\n+\n+\t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n+\t\"tangled.sh/tangled.sh/core/patchutil\"\n+)\n+\n+func main() {\n+\tif len(os.Args) != 3 {\n+\t\tfmt.Println(\"Usage: interdiff <patch1> <patch2>\")\n+\t\tos.Exit(1)\n+\t}\n+\n+\tpatch1, err := os.Open(os.Args[1])\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\tpatch2, err := os.Open(os.Args[2])\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tfiles1, _, err := gitdiff.Parse(patch1)\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tfiles2, _, err := gitdiff.Parse(patch2)\n+\tif err != nil {\n+\t\tfmt.Println(err)\n+\t}\n+\n+\tinterDiffResult := patchutil.Interdiff(files1, files2)\n+\tfmt.Println(interDiffResult)\n+}\ndiff --git a/go.mod b/go.mod\nindex 281a0b5..76b7143 100644\n--- a/go.mod\n+++ b/go.mod\n@@ -106,9 +106,9 @@ require (\n \tgo.uber.org/atomic v1.11.0 // indirect\n \tgo.uber.org/multierr v1.11.0 // indirect\n \tgo.uber.org/zap v1.26.0 // indirect\n-\tgolang.org/x/crypto v0.36.0 // indirect\n-\tgolang.org/x/net v0.37.0 // indirect\n-\tgolang.org/x/sys v0.31.0 // indirect\n+\tgolang.org/x/crypto v0.37.0 // indirect\n+\tgolang.org/x/net v0.39.0 // indirect\n+\tgolang.org/x/sys v0.32.0 // indirect\n \tgolang.org/x/time v0.5.0 // indirect\n \tgoogle.golang.org/protobuf v1.34.2 // indirect\n \tgopkg.in/warnings.v0 v0.1.2 // indirect\ndiff --git a/go.sum b/go.sum\nindex 20e3ac7..7ac12ce 100644\n--- a/go.sum\n+++ b/go.sum\n@@ -303,8 +303,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0\n golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\n golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=\n golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=\n-golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=\n-golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=\n+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=\n+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=\n golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=\n golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=\n golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\n@@ -327,8 +327,8 @@ golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfS\n golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=\n golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\n golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\n-golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=\n-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\n+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=\n+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=\n golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\n golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\n golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\n@@ -357,23 +357,23 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\n golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\n golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\n golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\n-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=\n-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\n+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=\n+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\n golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\n golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\n golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\n golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\n golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\n-golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=\n-golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=\n+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=\n+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=\n golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\n golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\n golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\n golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\n golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\n golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\n-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=\n-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=\n+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=\n+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=\n golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=\n golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\n golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ndiff --git a/patchutil/combinediff.go b/patchutil/combinediff.go\nnew file mode 100644\nindex 0000000..091ec88\n--- /dev/null\n+++ b/patchutil/combinediff.go\n@@ -0,0 +1,168 @@\n+package patchutil\n+\n+import (\n+\t\"fmt\"\n+\t\"strings\"\n+\n+\t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n+)\n+\n+// original1 -> patch1 -> rev1\n+// original2 -> patch2 -> rev2\n+//\n+// original2 must be equal to rev1, so we can merge them to get maximal context\n+//\n+// finally,\n+// rev2' <- apply(patch2, merged)\n+// combineddiff <- diff(rev2', original1)\n+func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {\n+\tfileName := bestName(file1)\n+\n+\to1 := CreatePreImage(file1)\n+\tr1 := CreatePostImage(file1)\n+\to2 := CreatePreImage(file2)\n+\n+\tmerged, err := r1.Merge(&o2)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\tr2Prime, err := merged.Apply(file2)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\t// produce combined diff\n+\tdiff, err := Unified(o1.String(), fileName, r2Prime, fileName)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\tparsed, _, err := gitdiff.Parse(strings.NewReader(diff))\n+\n+\tif len(parsed) != 1 {\n+\t\t// no diff? the second commit reverted the changes from the first\n+\t\treturn nil, nil\n+\t}\n+\n+\treturn parsed[0], nil\n+}\n+\n+// use empty lines for lines we are unaware of\n+//\n+// this raises an error only if the two patches were invalid or non-contiguous\n+func mergeLines(old, new string) (string, error) {\n+\tvar i, j int\n+\n+\t// TODO: use strings.Lines\n+\tlinesOld := strings.Split(old, \"\\n\")\n+\tlinesNew := strings.Split(new, \"\\n\")\n+\n+\tresult := []string{}\n+\n+\tfor i < len(linesOld) || j < len(linesNew) {\n+\t\tif i >= len(linesOld) {\n+\t\t\t// rest of the file is populated from `new`\n+\t\t\tresult = append(result, linesNew[j])\n+\t\t\tj++\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tif j >= len(linesNew) {\n+\t\t\t// rest of the file is populated from `old`\n+\t\t\tresult = append(result, linesOld[i])\n+\t\t\ti++\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\toldLine := linesOld[i]\n+\t\tnewLine := linesNew[j]\n+\n+\t\tif oldLine != newLine && (oldLine != \"\" && newLine != \"\") {\n+\t\t\t// context mismatch\n+\t\t\treturn \"\", fmt.Errorf(\"failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`\", i+1, oldLine, newLine)\n+\t\t}\n+\n+\t\tif oldLine == newLine {\n+\t\t\tresult = append(result, oldLine)\n+\t\t} else if oldLine == \"\" {\n+\t\t\tresult = append(result, newLine)\n+\t\t} else if newLine == \"\" {\n+\t\t\tresult = append(result, oldLine)\n+\t\t}\n+\t\ti++\n+\t\tj++\n+\t}\n+\n+\treturn strings.Join(result, \"\\n\"), nil\n+}\n+\n+func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {\n+\tfileToIdx1 := make(map[string]int)\n+\tfileToIdx2 := make(map[string]int)\n+\tvisited := make(map[string]struct{})\n+\tvar result []*gitdiff.File\n+\n+\tfor idx, f := range patch1 {\n+\t\tfileToIdx1[bestName(f)] = idx\n+\t}\n+\n+\tfor idx, f := range patch2 {\n+\t\tfileToIdx2[bestName(f)] = idx\n+\t}\n+\n+\tfor _, f1 := range patch1 {\n+\t\tfileName := bestName(f1)\n+\t\tif idx, ok := fileToIdx2[fileName]; ok {\n+\t\t\tf2 := patch2[idx]\n+\n+\t\t\t// we have f1 and f2, combine them\n+\t\t\tcombined, err := combineFiles(f1, f2)\n+\t\t\tif err != nil {\n+\t\t\t\tfmt.Println(err)\n+\t\t\t}\n+\n+\t\t\tresult = append(result, combined)\n+\t\t} else {\n+\t\t\t// only in patch1; add as-is\n+\t\t\tresult = append(result, f1)\n+\t\t}\n+\n+\t\tvisited[fileName] = struct{}{}\n+\t}\n+\n+\t// for all files in patch2 that remain unvisited; we can just add them into the output\n+\tfor _, f2 := range patch2 {\n+\t\tfileName := bestName(f2)\n+\t\tif _, ok := visited[fileName]; ok {\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tresult = append(result, f2)\n+\t}\n+\n+\treturn result\n+}\n+\n+// pairwise combination from first to last patch\n+func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {\n+\tif len(patches) == 0 {\n+\t\treturn nil\n+\t}\n+\n+\tif len(patches) == 1 {\n+\t\treturn patches[0]\n+\t}\n+\n+\tcombined := combineTwo(patches[0], patches[1])\n+\n+\tnewPatches := [][]*gitdiff.File{}\n+\tnewPatches = append(newPatches, combined)\n+\tfor i, p := range patches {\n+\t\tif i >= 2 {\n+\t\t\tnewPatches = append(newPatches, p)\n+\t\t}\n+\t}\n+\n+\treturn CombineDiff(newPatches...)\n+}\ndiff --git a/patchutil/image.go b/patchutil/image.go\nnew file mode 100644\nindex 0000000..1a12d22\n--- /dev/null\n+++ b/patchutil/image.go\n@@ -0,0 +1,178 @@\n+package patchutil\n+\n+import (\n+\t\"bytes\"\n+\t\"fmt\"\n+\t\"strings\"\n+\n+\t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n+)\n+\n+type Line struct {\n+\tLineNumber int64\n+\tContent string\n+\tIsUnknown bool\n+}\n+\n+func NewLineAt(lineNumber int64, content string) Line {\n+\treturn Line{\n+\t\tLineNumber: lineNumber,\n+\t\tContent: content,\n+\t\tIsUnknown: false,\n+\t}\n+}\n+\n+type Image struct {\n+\tFile string\n+\tData []*Line\n+}\n+\n+func (r *Image) String() string {\n+\tvar i, j int64\n+\tvar b strings.Builder\n+\tfor {\n+\t\ti += 1\n+\n+\t\tif int(j) >= (len(r.Data)) {\n+\t\t\tbreak\n+\t\t}\n+\n+\t\tif r.Data[j].LineNumber == i {\n+\t\t\t// b.WriteString(fmt.Sprintf(\"%d:\", r.Data[j].LineNumber))\n+\t\t\tb.WriteString(r.Data[j].Content)\n+\t\t\tj += 1\n+\t\t} else {\n+\t\t\t//b.WriteString(fmt.Sprintf(\"%d:\\n\", i))\n+\t\t\tb.WriteString(\"\\n\")\n+\t\t}\n+\t}\n+\n+\treturn b.String()\n+}\n+\n+func (r *Image) AddLine(line *Line) {\n+\tr.Data = append(r.Data, line)\n+}\n+\n+// rebuild the original file from a patch\n+func CreatePreImage(file *gitdiff.File) Image {\n+\trf := Image{\n+\t\tFile: bestName(file),\n+\t}\n+\n+\tfor _, fragment := range file.TextFragments {\n+\t\tposition := fragment.OldPosition\n+\t\tfor _, line := range fragment.Lines {\n+\t\t\tswitch line.Op {\n+\t\t\tcase gitdiff.OpContext:\n+\t\t\t\trl := NewLineAt(position, line.Line)\n+\t\t\t\trf.Data = append(rf.Data, &rl)\n+\t\t\t\tposition += 1\n+\t\t\tcase gitdiff.OpDelete:\n+\t\t\t\trl := NewLineAt(position, line.Line)\n+\t\t\t\trf.Data = append(rf.Data, &rl)\n+\t\t\t\tposition += 1\n+\t\t\tcase gitdiff.OpAdd:\n+\t\t\t\t// do nothing here\n+\t\t\t}\n+\t\t}\n+\t}\n+\n+\treturn rf\n+}\n+\n+// rebuild the revised file from a patch\n+func CreatePostImage(file *gitdiff.File) Image {\n+\trf := Image{\n+\t\tFile: bestName(file),\n+\t}\n+\n+\tfor _, fragment := range file.TextFragments {\n+\t\tposition := fragment.NewPosition\n+\t\tfor _, line := range fragment.Lines {\n+\t\t\tswitch line.Op {\n+\t\t\tcase gitdiff.OpContext:\n+\t\t\t\trl := NewLineAt(position, line.Line)\n+\t\t\t\trf.Data = append(rf.Data, &rl)\n+\t\t\t\tposition += 1\n+\t\t\tcase gitdiff.OpAdd:\n+\t\t\t\trl := NewLineAt(position, line.Line)\n+\t\t\t\trf.Data = append(rf.Data, &rl)\n+\t\t\t\tposition += 1\n+\t\t\tcase gitdiff.OpDelete:\n+\t\t\t\t// do nothing here\n+\t\t\t}\n+\t\t}\n+\t}\n+\n+\treturn rf\n+}\n+\n+type MergeError struct {\n+\tmsg string\n+\tmismatchingLine int64\n+}\n+\n+func (m MergeError) Error() string {\n+\treturn fmt.Sprintf(\"%s: %v\", m.msg, m.mismatchingLine)\n+}\n+\n+// best effort merging of two reconstructed files\n+func (this *Image) Merge(other *Image) (*Image, error) {\n+\tmergedFile := Image{}\n+\n+\tvar i, j int64\n+\n+\tfor int(i) < len(this.Data) || int(j) < len(other.Data) {\n+\t\tif int(i) >= len(this.Data) {\n+\t\t\t// first file is done; the rest of the lines from file 2 can go in\n+\t\t\tmergedFile.AddLine(other.Data[j])\n+\t\t\tj++\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tif int(j) >= len(other.Data) {\n+\t\t\t// first file is done; the rest of the lines from file 2 can go in\n+\t\t\tmergedFile.AddLine(this.Data[i])\n+\t\t\ti++\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tline1 := this.Data[i]\n+\t\tline2 := other.Data[j]\n+\n+\t\tif line1.LineNumber == line2.LineNumber {\n+\t\t\tif line1.Content != line2.Content {\n+\t\t\t\treturn nil, MergeError{\n+\t\t\t\t\tmsg: \"mismatching lines, this patch might have undergone rebase\",\n+\t\t\t\t\tmismatchingLine: line1.LineNumber,\n+\t\t\t\t}\n+\t\t\t} else {\n+\t\t\t\tmergedFile.AddLine(line1)\n+\t\t\t}\n+\t\t\ti++\n+\t\t\tj++\n+\t\t} else if line1.LineNumber < line2.LineNumber {\n+\t\t\tmergedFile.AddLine(line1)\n+\t\t\ti++\n+\t\t} else {\n+\t\t\tmergedFile.AddLine(line2)\n+\t\t\tj++\n+\t\t}\n+\t}\n+\n+\treturn &mergedFile, nil\n+}\n+\n+func (r *Image) Apply(patch *gitdiff.File) (string, error) {\n+\toriginal := r.String()\n+\tvar buffer bytes.Buffer\n+\treader := strings.NewReader(original)\n+\n+\terr := gitdiff.Apply(&buffer, reader, patch)\n+\tif err != nil {\n+\t\treturn \"\", err\n+\t}\n+\n+\treturn buffer.String(), nil\n+}\ndiff --git a/patchutil/interdiff.go b/patchutil/interdiff.go\nnew file mode 100644\nindex 0000000..6f4072b\n--- /dev/null\n+++ b/patchutil/interdiff.go\n@@ -0,0 +1,236 @@\n+package patchutil\n+\n+import (\n+\t\"fmt\"\n+\t\"strings\"\n+\n+\t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n+)\n+\n+type InterdiffResult struct {\n+\tFiles []*InterdiffFile\n+}\n+\n+func (i *InterdiffResult) String() string {\n+\tvar b strings.Builder\n+\tfor _, f := range i.Files {\n+\t\tb.WriteString(f.String())\n+\t\tb.WriteString(\"\\n\")\n+\t}\n+\n+\treturn b.String()\n+}\n+\n+type InterdiffFile struct {\n+\t*gitdiff.File\n+\tName string\n+\tStatus InterdiffFileStatus\n+}\n+\n+func (s *InterdiffFile) String() string {\n+\tvar b strings.Builder\n+\tb.WriteString(s.Status.String())\n+\tb.WriteString(\" \")\n+\n+\tif s.File != nil {\n+\t\tb.WriteString(bestName(s.File))\n+\t\tb.WriteString(\"\\n\")\n+\t\tb.WriteString(s.File.String())\n+\t}\n+\n+\treturn b.String()\n+}\n+\n+type InterdiffFileStatus struct {\n+\tStatusKind StatusKind\n+\tError error\n+}\n+\n+func (s *InterdiffFileStatus) String() string {\n+\tkind := s.StatusKind.String()\n+\tif s.Error != nil {\n+\t\treturn fmt.Sprintf(\"%s [%s]\", kind, s.Error.Error())\n+\t} else {\n+\t\treturn kind\n+\t}\n+}\n+\n+func (s *InterdiffFileStatus) IsOk() bool {\n+\treturn s.StatusKind == StatusOk\n+}\n+\n+func (s *InterdiffFileStatus) IsUnchanged() bool {\n+\treturn s.StatusKind == StatusUnchanged\n+}\n+\n+func (s *InterdiffFileStatus) IsOnlyInOne() bool {\n+\treturn s.StatusKind == StatusOnlyInOne\n+}\n+\n+func (s *InterdiffFileStatus) IsOnlyInTwo() bool {\n+\treturn s.StatusKind == StatusOnlyInTwo\n+}\n+\n+func (s *InterdiffFileStatus) IsRebased() bool {\n+\treturn s.StatusKind == StatusRebased\n+}\n+\n+func (s *InterdiffFileStatus) IsError() bool {\n+\treturn s.StatusKind == StatusError\n+}\n+\n+type StatusKind int\n+\n+func (k StatusKind) String() string {\n+\tswitch k {\n+\tcase StatusOnlyInOne:\n+\t\treturn \"only in one\"\n+\tcase StatusOnlyInTwo:\n+\t\treturn \"only in two\"\n+\tcase StatusUnchanged:\n+\t\treturn \"unchanged\"\n+\tcase StatusRebased:\n+\t\treturn \"rebased\"\n+\tcase StatusError:\n+\t\treturn \"error\"\n+\tdefault:\n+\t\treturn \"changed\"\n+\t}\n+}\n+\n+const (\n+\tStatusOk StatusKind = iota\n+\tStatusOnlyInOne\n+\tStatusOnlyInTwo\n+\tStatusUnchanged\n+\tStatusRebased\n+\tStatusError\n+)\n+\n+func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {\n+\tre1 := CreatePreImage(f1)\n+\tre2 := CreatePreImage(f2)\n+\n+\tinterdiffFile := InterdiffFile{\n+\t\tName: bestName(f1),\n+\t}\n+\n+\tmerged, err := re1.Merge(&re2)\n+\tif err != nil {\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusRebased,\n+\t\t\tError: err,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\trev1, err := merged.Apply(f1)\n+\tif err != nil {\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusError,\n+\t\t\tError: err,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\trev2, err := merged.Apply(f2)\n+\tif err != nil {\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusError,\n+\t\t\tError: err,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\tdiff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))\n+\tif err != nil {\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusError,\n+\t\t\tError: err,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\tparsed, _, err := gitdiff.Parse(strings.NewReader(diff))\n+\tif err != nil {\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusError,\n+\t\t\tError: err,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\tif len(parsed) != 1 {\n+\t\t// files are identical?\n+\t\tinterdiffFile.Status = InterdiffFileStatus{\n+\t\t\tStatusKind: StatusUnchanged,\n+\t\t}\n+\t\treturn &interdiffFile\n+\t}\n+\n+\tif interdiffFile.Status.StatusKind == StatusOk {\n+\t\tinterdiffFile.File = parsed[0]\n+\t}\n+\n+\treturn &interdiffFile\n+}\n+\n+func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {\n+\tfileToIdx1 := make(map[string]int)\n+\tfileToIdx2 := make(map[string]int)\n+\tvisited := make(map[string]struct{})\n+\tvar result InterdiffResult\n+\n+\tfor idx, f := range patch1 {\n+\t\tfileToIdx1[bestName(f)] = idx\n+\t}\n+\n+\tfor idx, f := range patch2 {\n+\t\tfileToIdx2[bestName(f)] = idx\n+\t}\n+\n+\tfor _, f1 := range patch1 {\n+\t\tvar interdiffFile *InterdiffFile\n+\n+\t\tfileName := bestName(f1)\n+\t\tif idx, ok := fileToIdx2[fileName]; ok {\n+\t\t\tf2 := patch2[idx]\n+\n+\t\t\t// we have f1 and f2, calculate interdiff\n+\t\t\tinterdiffFile = interdiffFiles(f1, f2)\n+\t\t} else {\n+\t\t\t// only in patch 1, this change would have to be \"inverted\" to dissapear\n+\t\t\t// from patch 2, so we reverseDiff(f1)\n+\t\t\treverseDiff(f1)\n+\n+\t\t\tinterdiffFile = &InterdiffFile{\n+\t\t\t\tFile: f1,\n+\t\t\t\tName: fileName,\n+\t\t\t\tStatus: InterdiffFileStatus{\n+\t\t\t\t\tStatusKind: StatusOnlyInOne,\n+\t\t\t\t},\n+\t\t\t}\n+\t\t}\n+\n+\t\tresult.Files = append(result.Files, interdiffFile)\n+\t\tvisited[fileName] = struct{}{}\n+\t}\n+\n+\t// for all files in patch2 that remain unvisited; we can just add them into the output\n+\tfor _, f2 := range patch2 {\n+\t\tfileName := bestName(f2)\n+\t\tif _, ok := visited[fileName]; ok {\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tresult.Files = append(result.Files, &InterdiffFile{\n+\t\t\tFile: f2,\n+\t\t\tName: fileName,\n+\t\t\tStatus: InterdiffFileStatus{\n+\t\t\t\tStatusKind: StatusOnlyInTwo,\n+\t\t\t},\n+\t\t})\n+\t}\n+\n+\treturn &result\n+}\ndiff --git a/patchutil/patchutil.go b/patchutil/patchutil.go\nindex eb9281f..8132e19 100644\n--- a/patchutil/patchutil.go\n+++ b/patchutil/patchutil.go\n@@ -2,6 +2,8 @@ package patchutil\n \n import (\n \t\"fmt\"\n+\t\"os\"\n+\t\"os/exec\"\n \t\"regexp\"\n \t\"strings\"\n \n@@ -125,3 +127,70 @@ func splitFormatPatch(patchText string) []string {\n \t}\n \treturn patches\n }\n+\n+func bestName(file *gitdiff.File) string {\n+\tif file.IsDelete {\n+\t\treturn file.OldName\n+\t} else {\n+\t\treturn file.NewName\n+\t}\n+}\n+\n+// in-place reverse of a diff\n+func reverseDiff(file *gitdiff.File) {\n+\tfile.OldName, file.NewName = file.NewName, file.OldName\n+\tfile.OldMode, file.NewMode = file.NewMode, file.OldMode\n+\tfile.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment\n+\n+\tfor _, fragment := range file.TextFragments {\n+\t\t// swap postions\n+\t\tfragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition\n+\t\tfragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines\n+\t\tfragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded\n+\n+\t\tfor i := range fragment.Lines {\n+\t\t\tswitch fragment.Lines[i].Op {\n+\t\t\tcase gitdiff.OpAdd:\n+\t\t\t\tfragment.Lines[i].Op = gitdiff.OpDelete\n+\t\t\tcase gitdiff.OpDelete:\n+\t\t\t\tfragment.Lines[i].Op = gitdiff.OpAdd\n+\t\t\tdefault:\n+\t\t\t\t// do nothing\n+\t\t\t}\n+\t\t}\n+\t}\n+}\n+\n+func Unified(oldText, oldFile, newText, newFile string) (string, error) {\n+\toldTemp, err := os.CreateTemp(\"\", \"old_*\")\n+\tif err != nil {\n+\t\treturn \"\", fmt.Errorf(\"failed to create temp file for oldText: %w\", err)\n+\t}\n+\tdefer os.Remove(oldTemp.Name())\n+\tif _, err := oldTemp.WriteString(oldText); err != nil {\n+\t\treturn \"\", fmt.Errorf(\"failed to write to old temp file: %w\", err)\n+\t}\n+\toldTemp.Close()\n+\n+\tnewTemp, err := os.CreateTemp(\"\", \"new_*\")\n+\tif err != nil {\n+\t\treturn \"\", fmt.Errorf(\"failed to create temp file for newText: %w\", err)\n+\t}\n+\tdefer os.Remove(newTemp.Name())\n+\tif _, err := newTemp.WriteString(newText); err != nil {\n+\t\treturn \"\", fmt.Errorf(\"failed to write to new temp file: %w\", err)\n+\t}\n+\tnewTemp.Close()\n+\n+\tcmd := exec.Command(\"diff\", \"-U\", \"9999\", \"--label\", oldFile, \"--label\", newFile, oldTemp.Name(), newTemp.Name())\n+\toutput, err := cmd.CombinedOutput()\n+\n+\tif exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {\n+\t\treturn string(output), nil\n+\t}\n+\tif err != nil {\n+\t\treturn \"\", fmt.Errorf(\"diff command failed: %w\", err)\n+\t}\n+\n+\treturn string(output), nil\n+}\n-- \n2.48.1\n", "title": "appview: implement interdiff", "pullId": 59, "targetRepo": "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.repo/3liuighjy2h22", "targetBranch": "master" } }