1package patchutil
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "regexp"
8 "strings"
9
10 "github.com/bluekeyes/go-gitdiff/gitdiff"
11)
12
13type FormatPatch struct {
14 Files []*gitdiff.File
15 *gitdiff.PatchHeader
16}
17
18// Extracts just the diff from this format-patch
19func (f FormatPatch) Patch() string {
20 var b strings.Builder
21 for _, p := range f.Files {
22 b.WriteString(p.String())
23 }
24 return b.String()
25}
26
27func (f FormatPatch) ChangeId() (string, error) {
28 if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 {
29 return vals[0], nil
30 }
31 return "", fmt.Errorf("no change-id found")
32}
33
34func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
35 patches := splitFormatPatch(formatPatch)
36
37 result := []FormatPatch{}
38
39 for _, patch := range patches {
40 files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
41 if err != nil {
42 return nil, fmt.Errorf("failed to parse patch: %w", err)
43 }
44
45 header, err := gitdiff.ParsePatchHeader(headerStr)
46 if err != nil {
47 return nil, fmt.Errorf("failed to parse patch header: %w", err)
48 }
49
50 result = append(result, FormatPatch{
51 Files: files,
52 PatchHeader: header,
53 })
54 }
55
56 return result, nil
57}
58
59// IsPatchValid checks if the given patch string is valid.
60// It performs very basic sniffing for either git-diff or git-format-patch
61// header lines. For format patches, it attempts to extract and validate each one.
62func IsPatchValid(patch string) bool {
63 if len(patch) == 0 {
64 return false
65 }
66
67 lines := strings.Split(patch, "\n")
68 if len(lines) < 2 {
69 return false
70 }
71
72 firstLine := strings.TrimSpace(lines[0])
73
74 // check if it's a git diff
75 if strings.HasPrefix(firstLine, "diff ") ||
76 strings.HasPrefix(firstLine, "--- ") ||
77 strings.HasPrefix(firstLine, "Index: ") ||
78 strings.HasPrefix(firstLine, "+++ ") ||
79 strings.HasPrefix(firstLine, "@@ ") {
80 return true
81 }
82
83 // check if it's format-patch
84 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
85 strings.HasPrefix(firstLine, "From: ") {
86 // ExtractPatches already runs it through gitdiff.Parse so if that errors,
87 // it's safe to say it's broken.
88 patches, err := ExtractPatches(patch)
89 if err != nil {
90 return false
91 }
92 return len(patches) > 0
93 }
94
95 return false
96}
97
98func IsFormatPatch(patch string) bool {
99 lines := strings.Split(patch, "\n")
100 if len(lines) < 2 {
101 return false
102 }
103
104 firstLine := strings.TrimSpace(lines[0])
105 if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") {
106 return true
107 }
108
109 headerCount := 0
110 for i := range min(10, len(lines)) {
111 line := strings.TrimSpace(lines[i])
112 if strings.HasPrefix(line, "From: ") ||
113 strings.HasPrefix(line, "Date: ") ||
114 strings.HasPrefix(line, "Subject: ") ||
115 strings.HasPrefix(line, "commit ") {
116 headerCount++
117 }
118 }
119
120 return headerCount >= 2
121}
122
123func splitFormatPatch(patchText string) []string {
124 re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
125
126 indexes := re.FindAllStringIndex(patchText, -1)
127
128 if len(indexes) == 0 {
129 return []string{}
130 }
131
132 patches := make([]string, len(indexes))
133
134 for i := range indexes {
135 startPos := indexes[i][0]
136 endPos := len(patchText)
137
138 if i < len(indexes)-1 {
139 endPos = indexes[i+1][0]
140 }
141
142 patches[i] = strings.TrimSpace(patchText[startPos:endPos])
143 }
144 return patches
145}
146
147func bestName(file *gitdiff.File) string {
148 if file.IsDelete {
149 return file.OldName
150 } else {
151 return file.NewName
152 }
153}
154
155// in-place reverse of a diff
156func reverseDiff(file *gitdiff.File) {
157 file.OldName, file.NewName = file.NewName, file.OldName
158 file.OldMode, file.NewMode = file.NewMode, file.OldMode
159 file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
160
161 for _, fragment := range file.TextFragments {
162 // swap postions
163 fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
164 fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
165 fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
166
167 for i := range fragment.Lines {
168 switch fragment.Lines[i].Op {
169 case gitdiff.OpAdd:
170 fragment.Lines[i].Op = gitdiff.OpDelete
171 case gitdiff.OpDelete:
172 fragment.Lines[i].Op = gitdiff.OpAdd
173 default:
174 // do nothing
175 }
176 }
177 }
178}
179
180func Unified(oldText, oldFile, newText, newFile string) (string, error) {
181 oldTemp, err := os.CreateTemp("", "old_*")
182 if err != nil {
183 return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
184 }
185 defer os.Remove(oldTemp.Name())
186 if _, err := oldTemp.WriteString(oldText); err != nil {
187 return "", fmt.Errorf("failed to write to old temp file: %w", err)
188 }
189 oldTemp.Close()
190
191 newTemp, err := os.CreateTemp("", "new_*")
192 if err != nil {
193 return "", fmt.Errorf("failed to create temp file for newText: %w", err)
194 }
195 defer os.Remove(newTemp.Name())
196 if _, err := newTemp.WriteString(newText); err != nil {
197 return "", fmt.Errorf("failed to write to new temp file: %w", err)
198 }
199 newTemp.Close()
200
201 cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
202 output, err := cmd.CombinedOutput()
203
204 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
205 return string(output), nil
206 }
207 if err != nil {
208 return "", fmt.Errorf("diff command failed: %w", err)
209 }
210
211 return string(output), nil
212}