Monorepo for Tangled tangled.org

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
··· 150 150 return false 151 151 } 152 152 153 - func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 153 + func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { 154 154 patch := s.Patch 155 155 156 - diffs, _, err := gitdiff.Parse(strings.NewReader(patch)) 156 + // if format-patch; then extract each patch 157 + var diffs []*gitdiff.File 158 + if patchutil.IsFormatPatch(patch) { 159 + patches, err := patchutil.ExtractPatches(patch) 160 + if err != nil { 161 + return nil, err 162 + } 163 + var ps [][]*gitdiff.File 164 + for _, p := range patches { 165 + ps = append(ps, p.Files) 166 + } 167 + 168 + diffs = patchutil.CombineDiff(ps...) 169 + } else { 170 + d, _, err := gitdiff.Parse(strings.NewReader(patch)) 171 + if err != nil { 172 + return nil, err 173 + } 174 + diffs = d 175 + } 176 + 177 + return diffs, nil 178 + } 179 + 180 + func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 181 + diffs, err := s.AsDiff(targetBranch) 157 182 if err != nil { 158 183 log.Println(err) 159 184 }
+2 -2
appview/pages/pages.go
··· 20 20 "tangled.sh/tangled.sh/core/appview/db" 21 21 "tangled.sh/tangled.sh/core/appview/pages/markup" 22 22 "tangled.sh/tangled.sh/core/appview/state/userutil" 23 - "tangled.sh/tangled.sh/core/interdiff" 23 + "tangled.sh/tangled.sh/core/patchutil" 24 24 "tangled.sh/tangled.sh/core/types" 25 25 26 26 "github.com/alecthomas/chroma/v2" ··· 715 715 RepoInfo RepoInfo 716 716 Pull *db.Pull 717 717 Round int 718 - Interdiff *interdiff.InterdiffResult 718 + Interdiff *patchutil.InterdiffResult 719 719 } 720 720 721 721 // this name is a mouthful
+7 -10
appview/pages/templates/repo/pulls/pull.html
··· 51 51 </span> 52 52 </div> 53 53 54 - {{ if $.Pull.IsPatchBased }} 55 - <!-- view patch --> 56 54 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 57 55 hx-boost="true" 58 56 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 59 57 {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 60 58 </a> 61 - {{ if not (eq .RoundNumber 0) }} 62 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 63 - hx-boost="true" 64 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 65 - {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span> 66 - </a> 67 - <span id="interdiff-error-{{.RoundNumber}}"></span> 68 - {{ end }} 59 + {{ if not (eq .RoundNumber 0) }} 60 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 61 + hx-boost="true" 62 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 63 + {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span> 64 + </a> 65 + <span id="interdiff-error-{{.RoundNumber}}"></span> 69 66 {{ end }} 70 67 </div> 71 68 </summary>
+3 -6
appview/state/pull.go
··· 10 10 "net/http" 11 11 "net/url" 12 12 "strconv" 13 - "strings" 14 13 "time" 15 14 16 15 "tangled.sh/tangled.sh/core/api/tangled" 17 16 "tangled.sh/tangled.sh/core/appview/auth" 18 17 "tangled.sh/tangled.sh/core/appview/db" 19 18 "tangled.sh/tangled.sh/core/appview/pages" 20 - "tangled.sh/tangled.sh/core/interdiff" 21 19 "tangled.sh/tangled.sh/core/patchutil" 22 20 "tangled.sh/tangled.sh/core/types" 23 21 24 - "github.com/bluekeyes/go-gitdiff/gitdiff" 25 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 23 "github.com/bluesky-social/indigo/atproto/syntax" 27 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 351 348 } 352 349 } 353 350 354 - currentPatch, _, err := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt].Patch)) 351 + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 355 352 if err != nil { 356 353 log.Println("failed to interdiff; current patch malformed") 357 354 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 358 355 return 359 356 } 360 357 361 - previousPatch, _, err := gitdiff.Parse(strings.NewReader(pull.Submissions[roundIdInt-1].Patch)) 358 + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 362 359 if err != nil { 363 360 log.Println("failed to interdiff; previous patch malformed") 364 361 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 365 362 return 366 363 } 367 364 368 - interdiff := interdiff.Interdiff(previousPatch, currentPatch) 365 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 369 366 370 367 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 371 368 LoggedInUser: s.auth.GetUser(r),
+38
cmd/combinediff/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: combinediff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + combined := patchutil.CombineDiff(files1, files2) 37 + fmt.Println(combined) 38 + }
+2 -2
cmd/interdiff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/interdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 9 ) 10 10 11 11 func main() { ··· 33 33 fmt.Println(err) 34 34 } 35 35 36 - interDiffResult := interdiff.Interdiff(files1, files2) 36 + interDiffResult := patchutil.Interdiff(files1, files2) 37 37 fmt.Println(interDiffResult) 38 38 }
-448
interdiff/interdiff.go
··· 1 - package interdiff 2 - 3 - import ( 4 - "bytes" 5 - "fmt" 6 - "os" 7 - "os/exec" 8 - "strings" 9 - 10 - "github.com/bluekeyes/go-gitdiff/gitdiff" 11 - ) 12 - 13 - type ReconstructedLine struct { 14 - LineNumber int64 15 - Content string 16 - IsUnknown bool 17 - } 18 - 19 - func NewLineAt(lineNumber int64, content string) ReconstructedLine { 20 - return ReconstructedLine{ 21 - LineNumber: lineNumber, 22 - Content: content, 23 - IsUnknown: false, 24 - } 25 - } 26 - 27 - type ReconstructedFile struct { 28 - File string 29 - Data []*ReconstructedLine 30 - } 31 - 32 - func (r *ReconstructedFile) String() string { 33 - var i, j int64 34 - var b strings.Builder 35 - for { 36 - i += 1 37 - 38 - if int(j) >= (len(r.Data)) { 39 - break 40 - } 41 - 42 - if r.Data[j].LineNumber == i { 43 - // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) 44 - b.WriteString(r.Data[j].Content) 45 - j += 1 46 - } else { 47 - //b.WriteString(fmt.Sprintf("%d:\n", i)) 48 - b.WriteString("\n") 49 - } 50 - } 51 - 52 - return b.String() 53 - } 54 - 55 - func (r *ReconstructedFile) AddLine(line *ReconstructedLine) { 56 - r.Data = append(r.Data, line) 57 - } 58 - 59 - func bestName(file *gitdiff.File) string { 60 - if file.IsDelete { 61 - return file.OldName 62 - } else { 63 - return file.NewName 64 - } 65 - } 66 - 67 - // in-place reverse of a diff 68 - func reverseDiff(file *gitdiff.File) { 69 - file.OldName, file.NewName = file.NewName, file.OldName 70 - file.OldMode, file.NewMode = file.NewMode, file.OldMode 71 - file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 72 - 73 - for _, fragment := range file.TextFragments { 74 - // swap postions 75 - fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 76 - fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 77 - fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 78 - 79 - for i := range fragment.Lines { 80 - switch fragment.Lines[i].Op { 81 - case gitdiff.OpAdd: 82 - fragment.Lines[i].Op = gitdiff.OpDelete 83 - case gitdiff.OpDelete: 84 - fragment.Lines[i].Op = gitdiff.OpAdd 85 - default: 86 - // do nothing 87 - } 88 - } 89 - } 90 - } 91 - 92 - // rebuild the original file from a patch 93 - func CreateOriginal(file *gitdiff.File) ReconstructedFile { 94 - rf := ReconstructedFile{ 95 - File: bestName(file), 96 - } 97 - 98 - for _, fragment := range file.TextFragments { 99 - position := fragment.OldPosition 100 - for _, line := range fragment.Lines { 101 - switch line.Op { 102 - case gitdiff.OpContext: 103 - rl := NewLineAt(position, line.Line) 104 - rf.Data = append(rf.Data, &rl) 105 - position += 1 106 - case gitdiff.OpDelete: 107 - rl := NewLineAt(position, line.Line) 108 - rf.Data = append(rf.Data, &rl) 109 - position += 1 110 - case gitdiff.OpAdd: 111 - // do nothing here 112 - } 113 - } 114 - } 115 - 116 - return rf 117 - } 118 - 119 - type MergeError struct { 120 - msg string 121 - mismatchingLine int64 122 - } 123 - 124 - func (m MergeError) Error() string { 125 - return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) 126 - } 127 - 128 - // best effort merging of two reconstructed files 129 - func (this *ReconstructedFile) Merge(other *ReconstructedFile) (*ReconstructedFile, error) { 130 - mergedFile := ReconstructedFile{} 131 - 132 - var i, j int64 133 - 134 - for int(i) < len(this.Data) || int(j) < len(other.Data) { 135 - if int(i) >= len(this.Data) { 136 - // first file is done; the rest of the lines from file 2 can go in 137 - mergedFile.AddLine(other.Data[j]) 138 - j++ 139 - continue 140 - } 141 - 142 - if int(j) >= len(other.Data) { 143 - // first file is done; the rest of the lines from file 2 can go in 144 - mergedFile.AddLine(this.Data[i]) 145 - i++ 146 - continue 147 - } 148 - 149 - line1 := this.Data[i] 150 - line2 := other.Data[j] 151 - 152 - if line1.LineNumber == line2.LineNumber { 153 - if line1.Content != line2.Content { 154 - return nil, MergeError{ 155 - msg: "mismatching lines, this patch might have undergone rebase", 156 - mismatchingLine: line1.LineNumber, 157 - } 158 - } else { 159 - mergedFile.AddLine(line1) 160 - } 161 - i++ 162 - j++ 163 - } else if line1.LineNumber < line2.LineNumber { 164 - mergedFile.AddLine(line1) 165 - i++ 166 - } else { 167 - mergedFile.AddLine(line2) 168 - j++ 169 - } 170 - } 171 - 172 - return &mergedFile, nil 173 - } 174 - 175 - func (r *ReconstructedFile) Apply(patch *gitdiff.File) (string, error) { 176 - original := r.String() 177 - var buffer bytes.Buffer 178 - reader := strings.NewReader(original) 179 - 180 - err := gitdiff.Apply(&buffer, reader, patch) 181 - if err != nil { 182 - return "", err 183 - } 184 - 185 - return buffer.String(), nil 186 - } 187 - 188 - func Unified(oldText, oldFile, newText, newFile string) (string, error) { 189 - oldTemp, err := os.CreateTemp("", "old_*") 190 - if err != nil { 191 - return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 192 - } 193 - defer os.Remove(oldTemp.Name()) 194 - if _, err := oldTemp.WriteString(oldText); err != nil { 195 - return "", fmt.Errorf("failed to write to old temp file: %w", err) 196 - } 197 - oldTemp.Close() 198 - 199 - newTemp, err := os.CreateTemp("", "new_*") 200 - if err != nil { 201 - return "", fmt.Errorf("failed to create temp file for newText: %w", err) 202 - } 203 - defer os.Remove(newTemp.Name()) 204 - if _, err := newTemp.WriteString(newText); err != nil { 205 - return "", fmt.Errorf("failed to write to new temp file: %w", err) 206 - } 207 - newTemp.Close() 208 - 209 - cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 210 - output, err := cmd.CombinedOutput() 211 - 212 - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 213 - return string(output), nil 214 - } 215 - if err != nil { 216 - return "", fmt.Errorf("diff command failed: %w", err) 217 - } 218 - 219 - return string(output), nil 220 - } 221 - 222 - type InterdiffResult struct { 223 - Files []*InterdiffFile 224 - } 225 - 226 - func (i *InterdiffResult) String() string { 227 - var b strings.Builder 228 - for _, f := range i.Files { 229 - b.WriteString(f.String()) 230 - b.WriteString("\n") 231 - } 232 - 233 - return b.String() 234 - } 235 - 236 - type InterdiffFile struct { 237 - *gitdiff.File 238 - Name string 239 - Status InterdiffFileStatus 240 - } 241 - 242 - func (s *InterdiffFile) String() string { 243 - var b strings.Builder 244 - b.WriteString(s.Status.String()) 245 - b.WriteString(" ") 246 - 247 - if s.File != nil { 248 - b.WriteString(bestName(s.File)) 249 - b.WriteString("\n") 250 - b.WriteString(s.File.String()) 251 - } 252 - 253 - return b.String() 254 - } 255 - 256 - type InterdiffFileStatus struct { 257 - StatusKind StatusKind 258 - Error error 259 - } 260 - 261 - func (s *InterdiffFileStatus) String() string { 262 - kind := s.StatusKind.String() 263 - if s.Error != nil { 264 - return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 265 - } else { 266 - return kind 267 - } 268 - } 269 - 270 - func (s *InterdiffFileStatus) IsOk() bool { 271 - return s.StatusKind == StatusOk 272 - } 273 - 274 - func (s *InterdiffFileStatus) IsUnchanged() bool { 275 - return s.StatusKind == StatusUnchanged 276 - } 277 - 278 - func (s *InterdiffFileStatus) IsOnlyInOne() bool { 279 - return s.StatusKind == StatusOnlyInOne 280 - } 281 - 282 - func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 283 - return s.StatusKind == StatusOnlyInTwo 284 - } 285 - 286 - func (s *InterdiffFileStatus) IsRebased() bool { 287 - return s.StatusKind == StatusRebased 288 - } 289 - 290 - func (s *InterdiffFileStatus) IsError() bool { 291 - return s.StatusKind == StatusError 292 - } 293 - 294 - type StatusKind int 295 - 296 - func (k StatusKind) String() string { 297 - switch k { 298 - case StatusOnlyInOne: 299 - return "only in one" 300 - case StatusOnlyInTwo: 301 - return "only in two" 302 - case StatusUnchanged: 303 - return "unchanged" 304 - case StatusRebased: 305 - return "rebased" 306 - case StatusError: 307 - return "error" 308 - default: 309 - return "changed" 310 - } 311 - } 312 - 313 - const ( 314 - StatusOk StatusKind = iota 315 - StatusOnlyInOne 316 - StatusOnlyInTwo 317 - StatusUnchanged 318 - StatusRebased 319 - StatusError 320 - ) 321 - 322 - func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 323 - re1 := CreateOriginal(f1) 324 - re2 := CreateOriginal(f2) 325 - 326 - interdiffFile := InterdiffFile{ 327 - Name: bestName(f1), 328 - } 329 - 330 - merged, err := re1.Merge(&re2) 331 - if err != nil { 332 - interdiffFile.Status = InterdiffFileStatus{ 333 - StatusKind: StatusRebased, 334 - Error: err, 335 - } 336 - return &interdiffFile 337 - } 338 - 339 - rev1, err := merged.Apply(f1) 340 - if err != nil { 341 - interdiffFile.Status = InterdiffFileStatus{ 342 - StatusKind: StatusError, 343 - Error: err, 344 - } 345 - return &interdiffFile 346 - } 347 - 348 - rev2, err := merged.Apply(f2) 349 - if err != nil { 350 - interdiffFile.Status = InterdiffFileStatus{ 351 - StatusKind: StatusError, 352 - Error: err, 353 - } 354 - return &interdiffFile 355 - } 356 - 357 - diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 358 - if err != nil { 359 - interdiffFile.Status = InterdiffFileStatus{ 360 - StatusKind: StatusError, 361 - Error: err, 362 - } 363 - return &interdiffFile 364 - } 365 - 366 - parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 367 - if err != nil { 368 - interdiffFile.Status = InterdiffFileStatus{ 369 - StatusKind: StatusError, 370 - Error: err, 371 - } 372 - return &interdiffFile 373 - } 374 - 375 - if len(parsed) != 1 { 376 - // files are identical? 377 - interdiffFile.Status = InterdiffFileStatus{ 378 - StatusKind: StatusUnchanged, 379 - } 380 - return &interdiffFile 381 - } 382 - 383 - if interdiffFile.Status.StatusKind == StatusOk { 384 - interdiffFile.File = parsed[0] 385 - } 386 - 387 - return &interdiffFile 388 - } 389 - 390 - func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 391 - fileToIdx1 := make(map[string]int) 392 - fileToIdx2 := make(map[string]int) 393 - visited := make(map[string]struct{}) 394 - var result InterdiffResult 395 - 396 - for idx, f := range patch1 { 397 - fileToIdx1[bestName(f)] = idx 398 - } 399 - 400 - for idx, f := range patch2 { 401 - fileToIdx2[bestName(f)] = idx 402 - } 403 - 404 - for _, f1 := range patch1 { 405 - var interdiffFile *InterdiffFile 406 - 407 - fileName := bestName(f1) 408 - if idx, ok := fileToIdx2[fileName]; ok { 409 - f2 := patch2[idx] 410 - 411 - // we have f1 and f2, calculate interdiff 412 - interdiffFile = interdiffFiles(f1, f2) 413 - } else { 414 - // only in patch 1, this change would have to be "inverted" to dissapear 415 - // from patch 2, so we reverseDiff(f1) 416 - reverseDiff(f1) 417 - 418 - interdiffFile = &InterdiffFile{ 419 - File: f1, 420 - Name: fileName, 421 - Status: InterdiffFileStatus{ 422 - StatusKind: StatusOnlyInOne, 423 - }, 424 - } 425 - } 426 - 427 - result.Files = append(result.Files, interdiffFile) 428 - visited[fileName] = struct{}{} 429 - } 430 - 431 - // for all files in patch2 that remain unvisited; we can just add them into the output 432 - for _, f2 := range patch2 { 433 - fileName := bestName(f2) 434 - if _, ok := visited[fileName]; ok { 435 - continue 436 - } 437 - 438 - result.Files = append(result.Files, &InterdiffFile{ 439 - File: f2, 440 - Name: fileName, 441 - Status: InterdiffFileStatus{ 442 - StatusKind: StatusOnlyInTwo, 443 - }, 444 - }) 445 - } 446 - 447 - return &result 448 - }
+168
patchutil/combinediff.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + // original1 -> patch1 -> rev1 11 + // original2 -> patch2 -> rev2 12 + // 13 + // original2 must be equal to rev1, so we can merge them to get maximal context 14 + // 15 + // finally, 16 + // rev2' <- apply(patch2, merged) 17 + // combineddiff <- diff(rev2', original1) 18 + func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) { 19 + fileName := bestName(file1) 20 + 21 + o1 := CreatePreImage(file1) 22 + r1 := CreatePostImage(file1) 23 + o2 := CreatePreImage(file2) 24 + 25 + merged, err := r1.Merge(&o2) 26 + if err != nil { 27 + return nil, err 28 + } 29 + 30 + r2Prime, err := merged.Apply(file2) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + // produce combined diff 36 + diff, err := Unified(o1.String(), fileName, r2Prime, fileName) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 42 + 43 + if len(parsed) != 1 { 44 + // no diff? the second commit reverted the changes from the first 45 + return nil, nil 46 + } 47 + 48 + return parsed[0], nil 49 + } 50 + 51 + // use empty lines for lines we are unaware of 52 + // 53 + // this raises an error only if the two patches were invalid or non-contiguous 54 + func mergeLines(old, new string) (string, error) { 55 + var i, j int 56 + 57 + // TODO: use strings.Lines 58 + linesOld := strings.Split(old, "\n") 59 + linesNew := strings.Split(new, "\n") 60 + 61 + result := []string{} 62 + 63 + for i < len(linesOld) || j < len(linesNew) { 64 + if i >= len(linesOld) { 65 + // rest of the file is populated from `new` 66 + result = append(result, linesNew[j]) 67 + j++ 68 + continue 69 + } 70 + 71 + if j >= len(linesNew) { 72 + // rest of the file is populated from `old` 73 + result = append(result, linesOld[i]) 74 + i++ 75 + continue 76 + } 77 + 78 + oldLine := linesOld[i] 79 + newLine := linesNew[j] 80 + 81 + if oldLine != newLine && (oldLine != "" && newLine != "") { 82 + // context mismatch 83 + return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine) 84 + } 85 + 86 + if oldLine == newLine { 87 + result = append(result, oldLine) 88 + } else if oldLine == "" { 89 + result = append(result, newLine) 90 + } else if newLine == "" { 91 + result = append(result, oldLine) 92 + } 93 + i++ 94 + j++ 95 + } 96 + 97 + return strings.Join(result, "\n"), nil 98 + } 99 + 100 + func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File { 101 + fileToIdx1 := make(map[string]int) 102 + fileToIdx2 := make(map[string]int) 103 + visited := make(map[string]struct{}) 104 + var result []*gitdiff.File 105 + 106 + for idx, f := range patch1 { 107 + fileToIdx1[bestName(f)] = idx 108 + } 109 + 110 + for idx, f := range patch2 { 111 + fileToIdx2[bestName(f)] = idx 112 + } 113 + 114 + for _, f1 := range patch1 { 115 + fileName := bestName(f1) 116 + if idx, ok := fileToIdx2[fileName]; ok { 117 + f2 := patch2[idx] 118 + 119 + // we have f1 and f2, combine them 120 + combined, err := combineFiles(f1, f2) 121 + if err != nil { 122 + fmt.Println(err) 123 + } 124 + 125 + result = append(result, combined) 126 + } else { 127 + // only in patch1; add as-is 128 + result = append(result, f1) 129 + } 130 + 131 + visited[fileName] = struct{}{} 132 + } 133 + 134 + // for all files in patch2 that remain unvisited; we can just add them into the output 135 + for _, f2 := range patch2 { 136 + fileName := bestName(f2) 137 + if _, ok := visited[fileName]; ok { 138 + continue 139 + } 140 + 141 + result = append(result, f2) 142 + } 143 + 144 + return result 145 + } 146 + 147 + // pairwise combination from first to last patch 148 + func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File { 149 + if len(patches) == 0 { 150 + return nil 151 + } 152 + 153 + if len(patches) == 1 { 154 + return patches[0] 155 + } 156 + 157 + combined := combineTwo(patches[0], patches[1]) 158 + 159 + newPatches := [][]*gitdiff.File{} 160 + newPatches = append(newPatches, combined) 161 + for i, p := range patches { 162 + if i >= 2 { 163 + newPatches = append(newPatches, p) 164 + } 165 + } 166 + 167 + return CombineDiff(newPatches...) 168 + }
+178
patchutil/image.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluekeyes/go-gitdiff/gitdiff" 9 + ) 10 + 11 + type Line struct { 12 + LineNumber int64 13 + Content string 14 + IsUnknown bool 15 + } 16 + 17 + func NewLineAt(lineNumber int64, content string) Line { 18 + return Line{ 19 + LineNumber: lineNumber, 20 + Content: content, 21 + IsUnknown: false, 22 + } 23 + } 24 + 25 + type Image struct { 26 + File string 27 + Data []*Line 28 + } 29 + 30 + func (r *Image) String() string { 31 + var i, j int64 32 + var b strings.Builder 33 + for { 34 + i += 1 35 + 36 + if int(j) >= (len(r.Data)) { 37 + break 38 + } 39 + 40 + if r.Data[j].LineNumber == i { 41 + // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) 42 + b.WriteString(r.Data[j].Content) 43 + j += 1 44 + } else { 45 + //b.WriteString(fmt.Sprintf("%d:\n", i)) 46 + b.WriteString("\n") 47 + } 48 + } 49 + 50 + return b.String() 51 + } 52 + 53 + func (r *Image) AddLine(line *Line) { 54 + r.Data = append(r.Data, line) 55 + } 56 + 57 + // rebuild the original file from a patch 58 + func CreatePreImage(file *gitdiff.File) Image { 59 + rf := Image{ 60 + File: bestName(file), 61 + } 62 + 63 + for _, fragment := range file.TextFragments { 64 + position := fragment.OldPosition 65 + for _, line := range fragment.Lines { 66 + switch line.Op { 67 + case gitdiff.OpContext: 68 + rl := NewLineAt(position, line.Line) 69 + rf.Data = append(rf.Data, &rl) 70 + position += 1 71 + case gitdiff.OpDelete: 72 + rl := NewLineAt(position, line.Line) 73 + rf.Data = append(rf.Data, &rl) 74 + position += 1 75 + case gitdiff.OpAdd: 76 + // do nothing here 77 + } 78 + } 79 + } 80 + 81 + return rf 82 + } 83 + 84 + // rebuild the revised file from a patch 85 + func CreatePostImage(file *gitdiff.File) Image { 86 + rf := Image{ 87 + File: bestName(file), 88 + } 89 + 90 + for _, fragment := range file.TextFragments { 91 + position := fragment.NewPosition 92 + for _, line := range fragment.Lines { 93 + switch line.Op { 94 + case gitdiff.OpContext: 95 + rl := NewLineAt(position, line.Line) 96 + rf.Data = append(rf.Data, &rl) 97 + position += 1 98 + case gitdiff.OpAdd: 99 + rl := NewLineAt(position, line.Line) 100 + rf.Data = append(rf.Data, &rl) 101 + position += 1 102 + case gitdiff.OpDelete: 103 + // do nothing here 104 + } 105 + } 106 + } 107 + 108 + return rf 109 + } 110 + 111 + type MergeError struct { 112 + msg string 113 + mismatchingLine int64 114 + } 115 + 116 + func (m MergeError) Error() string { 117 + return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) 118 + } 119 + 120 + // best effort merging of two reconstructed files 121 + func (this *Image) Merge(other *Image) (*Image, error) { 122 + mergedFile := Image{} 123 + 124 + var i, j int64 125 + 126 + for int(i) < len(this.Data) || int(j) < len(other.Data) { 127 + if int(i) >= len(this.Data) { 128 + // first file is done; the rest of the lines from file 2 can go in 129 + mergedFile.AddLine(other.Data[j]) 130 + j++ 131 + continue 132 + } 133 + 134 + if int(j) >= len(other.Data) { 135 + // first file is done; the rest of the lines from file 2 can go in 136 + mergedFile.AddLine(this.Data[i]) 137 + i++ 138 + continue 139 + } 140 + 141 + line1 := this.Data[i] 142 + line2 := other.Data[j] 143 + 144 + if line1.LineNumber == line2.LineNumber { 145 + if line1.Content != line2.Content { 146 + return nil, MergeError{ 147 + msg: "mismatching lines, this patch might have undergone rebase", 148 + mismatchingLine: line1.LineNumber, 149 + } 150 + } else { 151 + mergedFile.AddLine(line1) 152 + } 153 + i++ 154 + j++ 155 + } else if line1.LineNumber < line2.LineNumber { 156 + mergedFile.AddLine(line1) 157 + i++ 158 + } else { 159 + mergedFile.AddLine(line2) 160 + j++ 161 + } 162 + } 163 + 164 + return &mergedFile, nil 165 + } 166 + 167 + func (r *Image) Apply(patch *gitdiff.File) (string, error) { 168 + original := r.String() 169 + var buffer bytes.Buffer 170 + reader := strings.NewReader(original) 171 + 172 + err := gitdiff.Apply(&buffer, reader, patch) 173 + if err != nil { 174 + return "", err 175 + } 176 + 177 + return buffer.String(), nil 178 + }
+236
patchutil/interdiff.go
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + type InterdiffResult struct { 11 + Files []*InterdiffFile 12 + } 13 + 14 + func (i *InterdiffResult) String() string { 15 + var b strings.Builder 16 + for _, f := range i.Files { 17 + b.WriteString(f.String()) 18 + b.WriteString("\n") 19 + } 20 + 21 + return b.String() 22 + } 23 + 24 + type InterdiffFile struct { 25 + *gitdiff.File 26 + Name string 27 + Status InterdiffFileStatus 28 + } 29 + 30 + func (s *InterdiffFile) String() string { 31 + var b strings.Builder 32 + b.WriteString(s.Status.String()) 33 + b.WriteString(" ") 34 + 35 + if s.File != nil { 36 + b.WriteString(bestName(s.File)) 37 + b.WriteString("\n") 38 + b.WriteString(s.File.String()) 39 + } 40 + 41 + return b.String() 42 + } 43 + 44 + type InterdiffFileStatus struct { 45 + StatusKind StatusKind 46 + Error error 47 + } 48 + 49 + func (s *InterdiffFileStatus) String() string { 50 + kind := s.StatusKind.String() 51 + if s.Error != nil { 52 + return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 53 + } else { 54 + return kind 55 + } 56 + } 57 + 58 + func (s *InterdiffFileStatus) IsOk() bool { 59 + return s.StatusKind == StatusOk 60 + } 61 + 62 + func (s *InterdiffFileStatus) IsUnchanged() bool { 63 + return s.StatusKind == StatusUnchanged 64 + } 65 + 66 + func (s *InterdiffFileStatus) IsOnlyInOne() bool { 67 + return s.StatusKind == StatusOnlyInOne 68 + } 69 + 70 + func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 71 + return s.StatusKind == StatusOnlyInTwo 72 + } 73 + 74 + func (s *InterdiffFileStatus) IsRebased() bool { 75 + return s.StatusKind == StatusRebased 76 + } 77 + 78 + func (s *InterdiffFileStatus) IsError() bool { 79 + return s.StatusKind == StatusError 80 + } 81 + 82 + type StatusKind int 83 + 84 + func (k StatusKind) String() string { 85 + switch k { 86 + case StatusOnlyInOne: 87 + return "only in one" 88 + case StatusOnlyInTwo: 89 + return "only in two" 90 + case StatusUnchanged: 91 + return "unchanged" 92 + case StatusRebased: 93 + return "rebased" 94 + case StatusError: 95 + return "error" 96 + default: 97 + return "changed" 98 + } 99 + } 100 + 101 + const ( 102 + StatusOk StatusKind = iota 103 + StatusOnlyInOne 104 + StatusOnlyInTwo 105 + StatusUnchanged 106 + StatusRebased 107 + StatusError 108 + ) 109 + 110 + func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 111 + re1 := CreatePreImage(f1) 112 + re2 := CreatePreImage(f2) 113 + 114 + interdiffFile := InterdiffFile{ 115 + Name: bestName(f1), 116 + } 117 + 118 + merged, err := re1.Merge(&re2) 119 + if err != nil { 120 + interdiffFile.Status = InterdiffFileStatus{ 121 + StatusKind: StatusRebased, 122 + Error: err, 123 + } 124 + return &interdiffFile 125 + } 126 + 127 + rev1, err := merged.Apply(f1) 128 + if err != nil { 129 + interdiffFile.Status = InterdiffFileStatus{ 130 + StatusKind: StatusError, 131 + Error: err, 132 + } 133 + return &interdiffFile 134 + } 135 + 136 + rev2, err := merged.Apply(f2) 137 + if err != nil { 138 + interdiffFile.Status = InterdiffFileStatus{ 139 + StatusKind: StatusError, 140 + Error: err, 141 + } 142 + return &interdiffFile 143 + } 144 + 145 + diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 146 + if err != nil { 147 + interdiffFile.Status = InterdiffFileStatus{ 148 + StatusKind: StatusError, 149 + Error: err, 150 + } 151 + return &interdiffFile 152 + } 153 + 154 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 155 + if err != nil { 156 + interdiffFile.Status = InterdiffFileStatus{ 157 + StatusKind: StatusError, 158 + Error: err, 159 + } 160 + return &interdiffFile 161 + } 162 + 163 + if len(parsed) != 1 { 164 + // files are identical? 165 + interdiffFile.Status = InterdiffFileStatus{ 166 + StatusKind: StatusUnchanged, 167 + } 168 + return &interdiffFile 169 + } 170 + 171 + if interdiffFile.Status.StatusKind == StatusOk { 172 + interdiffFile.File = parsed[0] 173 + } 174 + 175 + return &interdiffFile 176 + } 177 + 178 + func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 179 + fileToIdx1 := make(map[string]int) 180 + fileToIdx2 := make(map[string]int) 181 + visited := make(map[string]struct{}) 182 + var result InterdiffResult 183 + 184 + for idx, f := range patch1 { 185 + fileToIdx1[bestName(f)] = idx 186 + } 187 + 188 + for idx, f := range patch2 { 189 + fileToIdx2[bestName(f)] = idx 190 + } 191 + 192 + for _, f1 := range patch1 { 193 + var interdiffFile *InterdiffFile 194 + 195 + fileName := bestName(f1) 196 + if idx, ok := fileToIdx2[fileName]; ok { 197 + f2 := patch2[idx] 198 + 199 + // we have f1 and f2, calculate interdiff 200 + interdiffFile = interdiffFiles(f1, f2) 201 + } else { 202 + // only in patch 1, this change would have to be "inverted" to dissapear 203 + // from patch 2, so we reverseDiff(f1) 204 + reverseDiff(f1) 205 + 206 + interdiffFile = &InterdiffFile{ 207 + File: f1, 208 + Name: fileName, 209 + Status: InterdiffFileStatus{ 210 + StatusKind: StatusOnlyInOne, 211 + }, 212 + } 213 + } 214 + 215 + result.Files = append(result.Files, interdiffFile) 216 + visited[fileName] = struct{}{} 217 + } 218 + 219 + // for all files in patch2 that remain unvisited; we can just add them into the output 220 + for _, f2 := range patch2 { 221 + fileName := bestName(f2) 222 + if _, ok := visited[fileName]; ok { 223 + continue 224 + } 225 + 226 + result.Files = append(result.Files, &InterdiffFile{ 227 + File: f2, 228 + Name: fileName, 229 + Status: InterdiffFileStatus{ 230 + StatusKind: StatusOnlyInTwo, 231 + }, 232 + }) 233 + } 234 + 235 + return &result 236 + }
+69
patchutil/patchutil.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "os" 6 + "os/exec" 5 7 "regexp" 6 8 "strings" 7 9 ··· 125 127 } 126 128 return patches 127 129 } 130 + 131 + func bestName(file *gitdiff.File) string { 132 + if file.IsDelete { 133 + return file.OldName 134 + } else { 135 + return file.NewName 136 + } 137 + } 138 + 139 + // in-place reverse of a diff 140 + func reverseDiff(file *gitdiff.File) { 141 + file.OldName, file.NewName = file.NewName, file.OldName 142 + file.OldMode, file.NewMode = file.NewMode, file.OldMode 143 + file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 144 + 145 + for _, fragment := range file.TextFragments { 146 + // swap postions 147 + fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 148 + fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 149 + fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 150 + 151 + for i := range fragment.Lines { 152 + switch fragment.Lines[i].Op { 153 + case gitdiff.OpAdd: 154 + fragment.Lines[i].Op = gitdiff.OpDelete 155 + case gitdiff.OpDelete: 156 + fragment.Lines[i].Op = gitdiff.OpAdd 157 + default: 158 + // do nothing 159 + } 160 + } 161 + } 162 + } 163 + 164 + func Unified(oldText, oldFile, newText, newFile string) (string, error) { 165 + oldTemp, err := os.CreateTemp("", "old_*") 166 + if err != nil { 167 + return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 168 + } 169 + defer os.Remove(oldTemp.Name()) 170 + if _, err := oldTemp.WriteString(oldText); err != nil { 171 + return "", fmt.Errorf("failed to write to old temp file: %w", err) 172 + } 173 + oldTemp.Close() 174 + 175 + newTemp, err := os.CreateTemp("", "new_*") 176 + if err != nil { 177 + return "", fmt.Errorf("failed to create temp file for newText: %w", err) 178 + } 179 + defer os.Remove(newTemp.Name()) 180 + if _, err := newTemp.WriteString(newText); err != nil { 181 + return "", fmt.Errorf("failed to write to new temp file: %w", err) 182 + } 183 + newTemp.Close() 184 + 185 + cmd := exec.Command("diff", "-U", "9999", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 186 + output, err := cmd.CombinedOutput() 187 + 188 + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 189 + return string(output), nil 190 + } 191 + if err != nil { 192 + return "", fmt.Errorf("diff command failed: %w", err) 193 + } 194 + 195 + return string(output), nil 196 + }