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}