// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package git import ( "net/mail" "regexp" "strings" "sync" "gitea.dev/modules/charset" "gitea.dev/modules/container" "gitea.dev/modules/util" ) // CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer. const CoAuthoredByTrailer = "Co-authored-by" type CommitIdentity struct { Name string Email string } // CommitMessageTrailerValues keys are all in lower-case type CommitMessageTrailerValues map[string][]string type CommitMessage struct { MessageRaw string messageUTF8 *string messageTitle *string messageBody *string trailerValues CommitMessageTrailerValues allParticipants []*CommitIdentity } func (c *CommitMessage) MessageUTF8() string { if c.messageUTF8 == nil { bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}}) c.messageUTF8 = new(util.UnsafeBytesToString(bs)) } return *c.messageUTF8 } func (c *CommitMessage) MessageTitle() string { if c.messageTitle == nil { s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n") c.messageTitle = new(strings.TrimSpace(s)) } return *c.messageTitle } func (c *CommitMessage) MessageBody() string { if c.messageBody == nil { _, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n") c.messageBody = new(strings.TrimSpace(s)) } return *c.messageBody } func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues { if c.trailerValues == nil { _, _, trailer := CommitMessageSplitTrailer(c.MessageUTF8()) c.trailerValues = CommitMessageParseTrailer(trailer) } return c.trailerValues } var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp { // the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n" return regexp.MustCompile(`(?s)^(?P.*?)(?P^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*)$`) }) func CommitMessageSplitTrailer(s string) (content, sep, trailer string) { s = util.NormalizeStringEOL(s) re := commitMessageTrailerSplit() v := re.FindStringSubmatch(s) if v == nil { return s, "", "" } return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")] } func CommitMessageParseTrailer(s string) CommitMessageTrailerValues { ret := CommitMessageTrailerValues{} for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") { k, v, ok := strings.Cut(line, ":") if !ok { continue } k, v = strings.TrimSpace(k), strings.TrimSpace(v) kLower := strings.ToLower(k) ret[kLower] = append(ret[kLower], v) } return ret } // AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author func (c *Commit) AllParticipantIdentities() []*CommitIdentity { if c.allParticipants != nil { return c.allParticipants } exclude := container.Set[string]{} c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email}) exclude.Add(strings.ToLower(c.Author.Email)) addParticipant := func(name, email string) { if name == "" && email == "" { return } emailLower := strings.ToLower(email) if emailLower != "" && exclude.Contains(emailLower) { return } c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email}) exclude.Add(emailLower) } addParticipant(c.Committer.Name, c.Committer.Email) for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] { addr, err := mail.ParseAddress(coAuthorValue) if err == nil { addParticipant(addr.Name, addr.Address) } else { addParticipant(coAuthorValue, "") } } return c.allParticipants }