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