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

move interdiff & combinediff into patchutil

Changed files
+730 -470
appview
db
pages
templates
repo
pulls
state
cmd
combinediff
interdiff
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)
}
+2 -2
appview/pages/pages.go
···
"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/interdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
"github.com/alecthomas/chroma/v2"
···
RepoInfo RepoInfo
Pull *db.Pull
Round int
-
Interdiff *interdiff.InterdiffResult
+
Interdiff *patchutil.InterdiffResult
}
// this name is a mouthful
+7 -10
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 }}
+
{{ 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>
+3 -6
appview/state/pull.go
···
"net/http"
"net/url"
"strconv"
-
"strings"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/interdiff"
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
···
}
}
-
currentPatch, _, err := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt].Patch))
+
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 := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt-1].Patch))
+
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 := interdiff.Interdiff(previousPatch, currentPatch)
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
LoggedInUser: s.auth.GetUser(r),
+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)
+
}
+2 -2
cmd/interdiff/main.go
···
"os"
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
"tangled.sh/tangled.sh/core/interdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
)
func main() {
···
fmt.Println(err)
}
-
interDiffResult := interdiff.Interdiff(files1, files2)
+
interDiffResult := patchutil.Interdiff(files1, files2)
fmt.Println(interDiffResult)
}
-448
interdiff/interdiff.go
···
-
package interdiff
-
-
import (
-
"bytes"
-
"fmt"
-
"os"
-
"os/exec"
-
"strings"
-
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
)
-
-
type ReconstructedLine struct {
-
LineNumber int64
-
Content string
-
IsUnknown bool
-
}
-
-
func NewLineAt(lineNumber int64, content string) ReconstructedLine {
-
return ReconstructedLine{
-
LineNumber: lineNumber,
-
Content: content,
-
IsUnknown: false,
-
}
-
}
-
-
type ReconstructedFile struct {
-
File string
-
Data []*ReconstructedLine
-
}
-
-
func (r *ReconstructedFile) 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 *ReconstructedFile) AddLine(line *ReconstructedLine) {
-
r.Data = append(r.Data, line)
-
}
-
-
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
-
}
-
}
-
}
-
}
-
-
// rebuild the original file from a patch
-
func CreateOriginal(file *gitdiff.File) ReconstructedFile {
-
rf := ReconstructedFile{
-
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
-
}
-
-
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 *ReconstructedFile) Merge(other *ReconstructedFile) (*ReconstructedFile, error) {
-
mergedFile := ReconstructedFile{}
-
-
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 *ReconstructedFile) 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
-
}
-
-
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", "--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
-
}
-
-
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 := CreateOriginal(f1)
-
re2 := CreateOriginal(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
-
}
+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
+
}