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