main
util.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "fmt"
8 "io"
9 "regexp"
10 "strconv"
11 "strings"
12
13 "github.com/bluekeyes/go-gitdiff/gitdiff"
14 "golang.org/x/crypto/ssh"
15)
16
17var (
18 baseCommitRe = regexp.MustCompile(`base-commit: (.+)\s*`)
19 startOfPatch = "From "
20 patchsetPrefix = "ps-"
21 prPrefix = "pr-"
22)
23
24func truncateSha(sha string) string {
25 if len(sha) < 7 {
26 return sha
27 }
28 return sha[:7]
29}
30
31func GetAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) {
32 keys := []ssh.PublicKey{}
33 for _, pubkey := range pubkeys {
34 if strings.TrimSpace(pubkey) == "" {
35 continue
36 }
37 if strings.HasPrefix(pubkey, "#") {
38 continue
39 }
40 upk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
41 if err != nil {
42 return keys, err
43 }
44 keys = append(keys, upk)
45 }
46
47 return keys, nil
48}
49
50func getFormattedPatchsetID(id int64) string {
51 if id == 0 {
52 return ""
53 }
54 return fmt.Sprintf("%s%d", patchsetPrefix, id)
55}
56
57func getPrID(prID string) (int64, error) {
58 recID, err := strconv.Atoi(strings.Replace(prID, prPrefix, "", 1))
59 if err != nil {
60 return 0, err
61 }
62 return int64(recID), nil
63}
64
65func getPatchsetID(patchsetID string) (int64, error) {
66 psID, err := strconv.Atoi(strings.Replace(patchsetID, patchsetPrefix, "", 1))
67 if err != nil {
68 return 0, err
69 }
70 return int64(psID), nil
71}
72
73func splitPatchSet(patchset string) []string {
74 return strings.Split(patchset, "\n"+startOfPatch)
75}
76
77func findBaseCommit(patch string) string {
78 strs := baseCommitRe.FindStringSubmatch(patch)
79 baseCommit := ""
80 if len(strs) > 1 {
81 baseCommit = strs[1]
82 }
83 return baseCommit
84}
85
86func patchToDiff(patch io.Reader) (string, error) {
87 by, err := io.ReadAll(patch)
88 if err != nil {
89 return "", err
90 }
91 str := string(by)
92 idx := strings.Index(str, "diff --git")
93 if idx == -1 {
94 return "", fmt.Errorf("no diff found in patch")
95 }
96 trailIdx := strings.LastIndex(str, "-- \n")
97 if trailIdx >= 0 {
98 return str[idx:trailIdx], nil
99 }
100 return str[idx:], nil
101}
102
103func ParsePatch(patchRaw string) ([]*gitdiff.File, string, error) {
104 reader := strings.NewReader(patchRaw)
105 diffFiles, preamble, err := gitdiff.Parse(reader)
106 return diffFiles, preamble, err
107}
108
109func ParsePatchset(patchset io.Reader) ([]*Patch, error) {
110 patches := []*Patch{}
111 buf := new(strings.Builder)
112 _, err := io.Copy(buf, patchset)
113 if err != nil {
114 return nil, err
115 }
116
117 if strings.TrimSpace(buf.String()) == "" {
118 return nil, fmt.Errorf("patchset is empty")
119 }
120
121 if !strings.HasPrefix(buf.String(), startOfPatch) {
122 return nil, fmt.Errorf("unrecognized patchset: must start with %q", startOfPatch)
123 }
124
125 patchesRaw := splitPatchSet(buf.String())
126 for idx, patchRaw := range patchesRaw {
127 patchStr := patchRaw
128 if idx > 0 {
129 patchStr = startOfPatch + patchRaw
130 }
131 diffFiles, preamble, err := ParsePatch(patchStr)
132 if err != nil {
133 return nil, err
134 }
135 header, err := gitdiff.ParsePatchHeader(preamble)
136 if err != nil {
137 return nil, err
138 }
139
140 baseCommit := findBaseCommit(patchRaw)
141 authorName := "Unknown"
142 authorEmail := ""
143 if header.Author != nil {
144 authorName = header.Author.Name
145 authorEmail = header.Author.Email
146 }
147
148 contentSha := calcContentSha(diffFiles, header)
149
150 patches = append(patches, &Patch{
151 AuthorName: authorName,
152 AuthorEmail: authorEmail,
153 AuthorDate: header.AuthorDate.UTC(),
154 Title: header.Title,
155 Body: header.Body,
156 BodyAppendix: header.BodyAppendix,
157 CommitSha: header.SHA,
158 ContentSha: contentSha,
159 RawText: patchStr,
160 BaseCommitSha: sql.NullString{String: baseCommit},
161 Files: diffFiles,
162 })
163 }
164
165 return patches, nil
166}
167
168// calcContentSha calculates a shasum containing the important content
169// changes related to a patch.
170// We cannot rely on patch.CommitSha because it includes the commit date
171// that will change when a user fetches and applies the patch locally.
172// We only include +/- lines (not context) so that rebased patches with
173// different context lines but identical changes are considered equal.
174func calcContentSha(diffFiles []*gitdiff.File, header *gitdiff.PatchHeader) string {
175 authorName := ""
176 authorEmail := ""
177 if header.Author != nil {
178 authorName = header.Author.Name
179 authorEmail = header.Author.Email
180 }
181 content := fmt.Sprintf(
182 "%s\n%s\n%s\n%s\n",
183 header.Title,
184 header.Body,
185 authorName,
186 authorEmail,
187 )
188 for _, diff := range diffFiles {
189 // we need to ignore diffs with base commit because that depends
190 // on the client that is exporting the patch
191 foundBase := false
192 for _, text := range diff.TextFragments {
193 for _, line := range text.Lines {
194 base := findBaseCommit(line.Line)
195 if base != "" {
196 foundBase = true
197 }
198 }
199 }
200
201 if foundBase {
202 continue
203 }
204
205 // Include file names and mode changes, but not OID prefixes since those
206 // change when context lines shift (e.g., after rebase)
207 dff := fmt.Sprintf(
208 "%s->%s %s->%s\n",
209 diff.OldName, diff.NewName,
210 diff.OldMode.String(), diff.NewMode.String(),
211 )
212 content += dff
213
214 // Include only added and deleted lines, not context lines.
215 // This ensures patches with identical changes but different context
216 // (due to rebasing) are considered equal.
217 for _, frag := range diff.TextFragments {
218 for _, line := range frag.Lines {
219 if line.Op == gitdiff.OpAdd || line.Op == gitdiff.OpDelete {
220 content += line.String()
221 }
222 }
223 }
224 }
225 sha := sha256.Sum256([]byte(content))
226 shaStr := hex.EncodeToString(sha[:])
227 return shaStr
228}