1package patchutil
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "regexp"
8 "slices"
9 "strings"
10
11 "github.com/bluekeyes/go-gitdiff/gitdiff"
12)
13
14type FormatPatch struct {
15 Files []*gitdiff.File
16 *gitdiff.PatchHeader
17 Raw string
18}
19
20func (f FormatPatch) ChangeId() (string, error) {
21 if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 {
22 return vals[0], nil
23 }
24 return "", fmt.Errorf("no change-id found")
25}
26
27func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
28 patches := splitFormatPatch(formatPatch)
29
30 result := []FormatPatch{}
31
32 for _, patch := range patches {
33 files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
34 if err != nil {
35 return nil, fmt.Errorf("failed to parse patch: %w", err)
36 }
37
38 header, err := gitdiff.ParsePatchHeader(headerStr)
39 if err != nil {
40 return nil, fmt.Errorf("failed to parse patch header: %w", err)
41 }
42
43 result = append(result, FormatPatch{
44 Files: files,
45 PatchHeader: header,
46 Raw: patch,
47 })
48 }
49
50 return result, nil
51}
52
53// IsPatchValid checks if the given patch string is valid.
54// It performs very basic sniffing for either git-diff or git-format-patch
55// header lines. For format patches, it attempts to extract and validate each one.
56func IsPatchValid(patch string) bool {
57 if len(patch) == 0 {
58 return false
59 }
60
61 lines := strings.Split(patch, "\n")
62 if len(lines) < 2 {
63 return false
64 }
65
66 firstLine := strings.TrimSpace(lines[0])
67
68 // check if it's a git diff
69 if strings.HasPrefix(firstLine, "diff ") ||
70 strings.HasPrefix(firstLine, "--- ") ||
71 strings.HasPrefix(firstLine, "Index: ") ||
72 strings.HasPrefix(firstLine, "+++ ") ||
73 strings.HasPrefix(firstLine, "@@ ") {
74 return true
75 }
76
77 // check if it's format-patch
78 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
79 strings.HasPrefix(firstLine, "From: ") {
80 // ExtractPatches already runs it through gitdiff.Parse so if that errors,
81 // it's safe to say it's broken.
82 patches, err := ExtractPatches(patch)
83 if err != nil {
84 return false
85 }
86 return len(patches) > 0
87 }
88
89 return false
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}