forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
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
52func IsPatchValid(patch string) error {
53 if len(patch) == 0 {
54 return EmptyPatchError
55 }
56
57 lines := strings.Split(patch, "\n")
58 if len(lines) < 2 {
59 return EmptyPatchError
60 }
61
62 firstLine := strings.TrimSpace(lines[0])
63
64 // check if it's a git diff
65 if strings.HasPrefix(firstLine, "diff ") ||
66 strings.HasPrefix(firstLine, "--- ") ||
67 strings.HasPrefix(firstLine, "Index: ") ||
68 strings.HasPrefix(firstLine, "+++ ") ||
69 strings.HasPrefix(firstLine, "@@ ") {
70 return nil
71 }
72
73 // check if it's format-patch
74 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
75 strings.HasPrefix(firstLine, "From: ") {
76 // ExtractPatches already runs it through gitdiff.Parse so if that errors,
77 // it's safe to say it's broken.
78 patches, err := ExtractPatches(patch)
79 if err != nil {
80 return fmt.Errorf("%w: %w", FormatPatchError, err)
81 }
82 if len(patches) == 0 {
83 return EmptyPatchError
84 }
85
86 return nil
87 }
88
89 return GenericPatchError
90}
91
92func IsFormatPatch(patch string) bool {
93 lines := strings.Split(patch, "\n")
94 if len(lines) < 2 {
95 return false
96 }
97
98 firstLine := strings.TrimSpace(lines[0])
99 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") {
100 return true
101 }
102
103 headerCount := 0
104 for i := range min(10, len(lines)) {
105 line := strings.TrimSpace(lines[i])
106 if strings.HasPrefix(line, "From: ") ||
107 strings.HasPrefix(line, "Date: ") ||
108 strings.HasPrefix(line, "Subject: ") ||
109 strings.HasPrefix(line, "commit ") {
110 headerCount++
111 }
112 }
113
114 return headerCount >= 2
115}
116
117func splitFormatPatch(patchText string) []string {
118 re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
119
120 indexes := re.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 nd.Commit.Parent = targetBranch
300
301 for _, d := range diffs {
302 ndiff := types.Diff{}
303 ndiff.Name.New = d.NewName
304 ndiff.Name.Old = d.OldName
305 ndiff.IsBinary = d.IsBinary
306 ndiff.IsNew = d.IsNew
307 ndiff.IsDelete = d.IsDelete
308 ndiff.IsCopy = d.IsCopy
309 ndiff.IsRename = d.IsRename
310
311 for _, tf := range d.TextFragments {
312 ndiff.TextFragments = append(ndiff.TextFragments, *tf)
313 for _, l := range tf.Lines {
314 switch l.Op {
315 case gitdiff.OpAdd:
316 nd.Stat.Insertions += 1
317 case gitdiff.OpDelete:
318 nd.Stat.Deletions += 1
319 }
320 }
321 }
322
323 nd.Diff = append(nd.Diff, ndiff)
324 }
325
326 nd.Stat.FilesChanged = len(diffs)
327
328 return nd
329}