forked from tangled.org/core
this repo has no description

appview: implement interdiff

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

Changed files
+1038 -23
appview
db
pages
templates
repo
state
cmd
combinediff
interdiff
patchutil
+27 -2
appview/db/pulls.go
···
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)
}
+21 -5
appview/pages/pages.go
···
"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
···
// 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 {
+148
appview/pages/templates/repo/fragments/interdiff.html
···
+
{{ define "repo/fragments/interdiff" }}
+
{{ $repo := index . 0 }}
+
{{ $x := index . 1 }}
+
{{ $diff := $x.Files }}
+
+
<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">
+
<div class="diff-stat">
+
<div class="flex gap-2 items-center">
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+
</div>
+
<div class="overflow-x-auto">
+
<ul class="dark:text-gray-200">
+
{{ range $diff }}
+
<li><a href="#file-{{ .Name }}" class="dark:hover:text-gray-300">{{ .Name }}</a></li>
+
{{ end }}
+
</ul>
+
</div>
+
</div>
+
</section>
+
+
{{ $last := sub (len $diff) 1 }}
+
{{ range $idx, $hunk := $diff }}
+
{{ with $hunk }}
+
<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">
+
<div id="file-{{ .Name }}">
+
<div id="diff-file">
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<div class="flex gap-1 items-center" style="direction: ltr;">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
{{ if .Status.IsOk }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
+
{{ else if .Status.IsUnchanged }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
+
{{ else if .Status.IsOnlyInOne }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
+
{{ else if .Status.IsOnlyInTwo }}
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
+
{{ else if .Status.IsRebased }}
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
+
{{ else }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
+
{{ end }}
+
</div>
+
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
+
{{ .Name }}
+
</a>
+
</div>
+
</div>
+
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
+
<div id="right-side-items" class="p-2 flex items-center">
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
+
{{ if gt $idx 0 }}
+
{{ $prev := index $diff (sub $idx 1) }}
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
+
{{ end }}
+
+
{{ if lt $idx $last }}
+
{{ $next := index $diff (add $idx 1) }}
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
+
{{ end }}
+
</div>
+
+
</div>
+
</summary>
+
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .Status.IsUnchanged }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has not been changed.
+
</p>
+
{{ else if .Status.IsRebased }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This patch was likely rebased, as context lines do not match.
+
</p>
+
{{ else if .Status.IsError }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
Failed to calculate interdiff for this file.
+
</p>
+
{{ else }}
+
{{ $name := .Name }}
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- $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 "+" -}}
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
{{- end -}}
+
</div>
+
+
</details>
+
+
</div>
+
</div>
+
</section>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "statPill" }}
+
<div class="flex items-center font-mono text-sm">
+
{{ if and .Insertions .Deletions }}
+
<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>
+
<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>
+
{{ else if .Insertions }}
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
{{ else if .Deletions }}
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ end }}
+
</div>
+
{{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
···
+
{{ define "title" }}
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "content" }}
+
<section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
+
<header class="pb-2">
+
<div class="flex gap-3 items-center mb-3">
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+
{{ i "arrow-left" "w-5 h-5" }}
+
back
+
</a>
+
<span class="select-none before:content-['\00B7']"></span>
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}
+
</div>
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
+
{{ template "repo/pulls/fragments/pullHeader" . }}
+
</header>
+
</section>
+
+
<section>
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
+
</section>
+
{{ end }}
+
+7 -2
appview/pages/templates/repo/pulls/pull.html
···
</span>
</div>
-
{{ if $.Pull.IsPatchBased }}
-
<!-- view patch -->
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
hx-boost="true"
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
</a>
+
{{ if not (eq .RoundNumber 0) }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
+
</a>
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
{{ end }}
</div>
</summary>
+69 -1
appview/state/pull.go
···
"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"
···
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
···
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) {
+1
appview/state/router.go
···
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)
+38
cmd/combinediff/main.go
···
+
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 <patch1> <patch2>")
+
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)
+
}
+38
cmd/interdiff/main.go
···
+
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 <patch1> <patch2>")
+
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)
+
}
+3 -3
go.mod
···
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
+10 -10
go.sum
···
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=
···
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=
···
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=
+168
patchutil/combinediff.go
···
+
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...)
+
}
+178
patchutil/image.go
···
+
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
+
}
+236
patchutil/interdiff.go
···
+
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
+
}
+69
patchutil/patchutil.go
···
import (
"fmt"
+
"os"
+
"os/exec"
"regexp"
"strings"
···
}
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
+
}