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