repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

jolheiser  ·  2025-08-21

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.
175func calcContentSha(diffFiles []*gitdiff.File, header *gitdiff.PatchHeader) string {
176	authorName := ""
177	authorEmail := ""
178	if header.Author != nil {
179		authorName = header.Author.Name
180		authorEmail = header.Author.Email
181	}
182	content := fmt.Sprintf(
183		"%s\n%s\n%s\n%s\n",
184		header.Title,
185		header.Body,
186		authorName,
187		authorEmail,
188	)
189	for _, diff := range diffFiles {
190		// we need to ignore diffs with base commit because that depends
191		// on the client that is exporting the patch
192		foundBase := false
193		for _, text := range diff.TextFragments {
194			for _, line := range text.Lines {
195				base := findBaseCommit(line.Line)
196				if base != "" {
197					foundBase = true
198				}
199			}
200		}
201
202		if foundBase {
203			continue
204		}
205
206		dff := fmt.Sprintf(
207			"%s->%s %s..%s %s->%s\n",
208			diff.OldName, diff.NewName,
209			diff.OldOIDPrefix, diff.NewOIDPrefix,
210			diff.OldMode.String(), diff.NewMode.String(),
211		)
212		content += dff
213	}
214	sha := sha256.Sum256([]byte(content))
215	shaStr := hex.EncodeToString(sha[:])
216	return shaStr
217}