Monorepo for Tangled
at master 328 lines 8.0 kB view raw
1package patchutil 2 3import ( 4 "errors" 5 "fmt" 6 "log" 7 "os" 8 "os/exec" 9 "regexp" 10 "slices" 11 "strings" 12 13 "github.com/bluekeyes/go-gitdiff/gitdiff" 14 "tangled.org/core/types" 15) 16 17func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) { 18 patches := splitFormatPatch(formatPatch) 19 20 result := []types.FormatPatch{} 21 22 for _, patch := range patches { 23 files, headerStr, err := gitdiff.Parse(strings.NewReader(patch)) 24 if err != nil { 25 return nil, fmt.Errorf("failed to parse patch: %w", err) 26 } 27 28 header, err := gitdiff.ParsePatchHeader(headerStr) 29 if err != nil { 30 return nil, fmt.Errorf("failed to parse patch header: %w", err) 31 } 32 33 result = append(result, types.FormatPatch{ 34 Files: files, 35 PatchHeader: header, 36 Raw: patch, 37 }) 38 } 39 40 return result, nil 41} 42 43// IsPatchValid checks if the given patch string is valid. 44// It performs very basic sniffing for either git-diff or git-format-patch 45// header lines. For format patches, it attempts to extract and validate each one. 46var ( 47 EmptyPatchError error = errors.New("patch is empty") 48 GenericPatchError error = errors.New("patch is invalid") 49 FormatPatchError error = errors.New("patch is not a valid format-patch") 50 51 formatPatchSplitRe = regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`) 52) 53 54func IsPatchValid(patch string) error { 55 if len(patch) == 0 { 56 return EmptyPatchError 57 } 58 59 lines := strings.Split(patch, "\n") 60 if len(lines) < 2 { 61 return EmptyPatchError 62 } 63 64 firstLine := strings.TrimSpace(lines[0]) 65 66 // check if it's a git diff 67 if strings.HasPrefix(firstLine, "diff ") || 68 strings.HasPrefix(firstLine, "--- ") || 69 strings.HasPrefix(firstLine, "Index: ") || 70 strings.HasPrefix(firstLine, "+++ ") || 71 strings.HasPrefix(firstLine, "@@ ") { 72 return nil 73 } 74 75 // check if it's format-patch 76 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") || 77 strings.HasPrefix(firstLine, "From: ") { 78 // ExtractPatches already runs it through gitdiff.Parse so if that errors, 79 // it's safe to say it's broken. 80 patches, err := ExtractPatches(patch) 81 if err != nil { 82 return fmt.Errorf("%w: %w", FormatPatchError, err) 83 } 84 if len(patches) == 0 { 85 return EmptyPatchError 86 } 87 88 return nil 89 } 90 91 return GenericPatchError 92} 93 94func IsFormatPatch(patch string) bool { 95 lines := strings.Split(patch, "\n") 96 if len(lines) < 2 { 97 return false 98 } 99 100 firstLine := strings.TrimSpace(lines[0]) 101 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") { 102 return true 103 } 104 105 headerCount := 0 106 for i := range min(10, len(lines)) { 107 line := strings.TrimSpace(lines[i]) 108 if strings.HasPrefix(line, "From: ") || 109 strings.HasPrefix(line, "Date: ") || 110 strings.HasPrefix(line, "Subject: ") || 111 strings.HasPrefix(line, "commit ") { 112 headerCount++ 113 } 114 } 115 116 return headerCount >= 2 117} 118 119func splitFormatPatch(patchText string) []string { 120 indexes := formatPatchSplitRe.FindAllStringIndex(patchText, -1) 121 122 if len(indexes) == 0 { 123 return []string{} 124 } 125 126 patches := make([]string, len(indexes)) 127 128 for i := range indexes { 129 startPos := indexes[i][0] 130 endPos := len(patchText) 131 132 if i < len(indexes)-1 { 133 endPos = indexes[i+1][0] 134 } 135 136 patches[i] = strings.TrimSpace(patchText[startPos:endPos]) 137 } 138 return patches 139} 140 141func bestName(file *gitdiff.File) string { 142 if file.IsDelete { 143 return file.OldName 144 } else { 145 return file.NewName 146 } 147} 148 149// in-place reverse of a diff 150func reverseDiff(file *gitdiff.File) { 151 file.OldName, file.NewName = file.NewName, file.OldName 152 file.OldMode, file.NewMode = file.NewMode, file.OldMode 153 file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 154 155 for _, fragment := range file.TextFragments { 156 // swap postions 157 fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 158 fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 159 fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 160 161 for i := range fragment.Lines { 162 switch fragment.Lines[i].Op { 163 case gitdiff.OpAdd: 164 fragment.Lines[i].Op = gitdiff.OpDelete 165 case gitdiff.OpDelete: 166 fragment.Lines[i].Op = gitdiff.OpAdd 167 default: 168 // do nothing 169 } 170 } 171 } 172} 173 174func Unified(oldText, oldFile, newText, newFile string) (string, error) { 175 oldTemp, err := os.CreateTemp("", "old_*") 176 if err != nil { 177 return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 178 } 179 defer os.Remove(oldTemp.Name()) 180 if _, err := oldTemp.WriteString(oldText); err != nil { 181 return "", fmt.Errorf("failed to write to old temp file: %w", err) 182 } 183 oldTemp.Close() 184 185 newTemp, err := os.CreateTemp("", "new_*") 186 if err != nil { 187 return "", fmt.Errorf("failed to create temp file for newText: %w", err) 188 } 189 defer os.Remove(newTemp.Name()) 190 if _, err := newTemp.WriteString(newText); err != nil { 191 return "", fmt.Errorf("failed to write to new temp file: %w", err) 192 } 193 newTemp.Close() 194 195 cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 196 output, err := cmd.CombinedOutput() 197 198 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 199 return string(output), nil 200 } 201 if err != nil { 202 return "", fmt.Errorf("diff command failed: %w", err) 203 } 204 205 return string(output), nil 206} 207 208// are two patches identical 209func Equal(a, b []*gitdiff.File) bool { 210 return slices.EqualFunc(a, b, func(x, y *gitdiff.File) bool { 211 // same pointer 212 if x == y { 213 return true 214 } 215 if x == nil || y == nil { 216 return x == y 217 } 218 219 // compare file metadata 220 if x.OldName != y.OldName || x.NewName != y.NewName { 221 return false 222 } 223 if x.OldMode != y.OldMode || x.NewMode != y.NewMode { 224 return false 225 } 226 if x.IsNew != y.IsNew || x.IsDelete != y.IsDelete || x.IsCopy != y.IsCopy || x.IsRename != y.IsRename { 227 return false 228 } 229 230 if len(x.TextFragments) != len(y.TextFragments) { 231 return false 232 } 233 234 for i, xFrag := range x.TextFragments { 235 yFrag := y.TextFragments[i] 236 237 // Compare fragment headers 238 if xFrag.OldPosition != yFrag.OldPosition || xFrag.OldLines != yFrag.OldLines || 239 xFrag.NewPosition != yFrag.NewPosition || xFrag.NewLines != yFrag.NewLines { 240 return false 241 } 242 243 // Compare fragment changes 244 if len(xFrag.Lines) != len(yFrag.Lines) { 245 return false 246 } 247 248 for j, xLine := range xFrag.Lines { 249 yLine := yFrag.Lines[j] 250 if xLine.Op != yLine.Op || xLine.Line != yLine.Line { 251 return false 252 } 253 } 254 } 255 256 return true 257 }) 258} 259 260// sort patch files in alphabetical order 261func SortPatch(patch []*gitdiff.File) { 262 slices.SortFunc(patch, func(a, b *gitdiff.File) int { 263 return strings.Compare(bestName(a), bestName(b)) 264 }) 265} 266 267func AsDiff(patch string) ([]*gitdiff.File, error) { 268 // if format-patch; then extract each patch 269 var diffs []*gitdiff.File 270 if IsFormatPatch(patch) { 271 patches, err := ExtractPatches(patch) 272 if err != nil { 273 return nil, err 274 } 275 var ps [][]*gitdiff.File 276 for _, p := range patches { 277 ps = append(ps, p.Files) 278 } 279 280 diffs = CombineDiff(ps...) 281 } else { 282 d, _, err := gitdiff.Parse(strings.NewReader(patch)) 283 if err != nil { 284 return nil, err 285 } 286 diffs = d 287 } 288 289 return diffs, nil 290} 291 292func AsNiceDiff(patch, targetBranch string) types.NiceDiff { 293 diffs, err := AsDiff(patch) 294 if err != nil { 295 log.Println(err) 296 } 297 298 nd := types.NiceDiff{} 299 300 for _, d := range diffs { 301 ndiff := types.Diff{} 302 ndiff.Name.New = d.NewName 303 ndiff.Name.Old = d.OldName 304 ndiff.IsBinary = d.IsBinary 305 ndiff.IsNew = d.IsNew 306 ndiff.IsDelete = d.IsDelete 307 ndiff.IsCopy = d.IsCopy 308 ndiff.IsRename = d.IsRename 309 310 for _, tf := range d.TextFragments { 311 ndiff.TextFragments = append(ndiff.TextFragments, *tf) 312 for _, l := range tf.Lines { 313 switch l.Op { 314 case gitdiff.OpAdd: 315 nd.Stat.Insertions += 1 316 case gitdiff.OpDelete: 317 nd.Stat.Deletions += 1 318 } 319 } 320 } 321 322 nd.Diff = append(nd.Diff, ndiff) 323 } 324 325 nd.Stat.FilesChanged = len(diffs) 326 327 return nd 328}