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