mirror of
https://github.com/bluekeyes/go-gitdiff
synced 2026-06-08 13:18:30 +00:00
1bd59c4f11
...when parsing emails, similar to `git am`. Add a new field, `BodyAppendix` to PatchHeader. Modify `scanMessageBody` to accept a boolean argument saying whether to separate out the appendix or not. Do this by keeping two string builders, and having it switch to the appendix builder when it finds a `---` line. Handling the newlines at the end as expected requires moving things around a bit. First, we were trimming space from the line once to decide whether the line was empty, and then trimming space again if we determined it wasn't empty. This only needs to be done once. Then, do all the trimming (both of whitespace and the prefix) first, before deciding what to do about the line. Request BodyAppendix separately when parsing a mail, but not a commit message. Add some tests to verify that it works as expected. Signed-off-by: George Dunlap <george.dunlap@citrix.com> Co-authored-by: George Dunlap <george.dunlap@citrix.com>
456 lines
11 KiB
Go
456 lines
11 KiB
Go
package gitdiff
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
const (
|
|
mailHeaderPrefix = "From "
|
|
prettyHeaderPrefix = "commit "
|
|
)
|
|
|
|
// PatchHeader is a parsed version of the preamble content that appears before
|
|
// the first diff in a patch. It includes metadata about the patch, such as the
|
|
// author and a subject.
|
|
type PatchHeader struct {
|
|
// The SHA of the commit the patch was generated from. Empty if the SHA is
|
|
// not included in the header.
|
|
SHA string
|
|
|
|
// The author details of the patch. If these details are not included in
|
|
// the header, Author is nil and AuthorDate is the zero time.
|
|
Author *PatchIdentity
|
|
AuthorDate time.Time
|
|
|
|
// The committer details of the patch. If these details are not included in
|
|
// the header, Committer is nil and CommitterDate is the zero time.
|
|
Committer *PatchIdentity
|
|
CommitterDate time.Time
|
|
|
|
// The title and body of the commit message describing the changes in the
|
|
// patch. Empty if no message is included in the header.
|
|
Title string
|
|
Body string
|
|
|
|
// If the preamble looks like an email, ParsePatchHeader will
|
|
// remove prefixes such as `Re: ` and `[PATCH v3 5/17]` from the
|
|
// Title and place them here.
|
|
SubjectPrefix string
|
|
|
|
// If the preamble looks like an email, and it contains a `---`
|
|
// line, that line will be removed and everything after it will be
|
|
// placed in BodyAppendix.
|
|
BodyAppendix string
|
|
}
|
|
|
|
// Message returns the commit message for the header. The message consists of
|
|
// the title and the body separated by an empty line.
|
|
func (h *PatchHeader) Message() string {
|
|
var msg strings.Builder
|
|
if h != nil {
|
|
msg.WriteString(h.Title)
|
|
if h.Body != "" {
|
|
msg.WriteString("\n\n")
|
|
msg.WriteString(h.Body)
|
|
}
|
|
}
|
|
return msg.String()
|
|
}
|
|
|
|
// PatchIdentity identifies a person who authored or committed a patch.
|
|
type PatchIdentity struct {
|
|
Name string
|
|
Email string
|
|
}
|
|
|
|
func (i PatchIdentity) String() string {
|
|
name := i.Name
|
|
if name == "" {
|
|
name = `""`
|
|
}
|
|
return fmt.Sprintf("%s <%s>", name, i.Email)
|
|
}
|
|
|
|
// ParsePatchIdentity parses a patch identity string. A valid string contains a
|
|
// non-empty name followed by an email address in angle brackets. Like Git,
|
|
// ParsePatchIdentity does not require that the email address is valid or
|
|
// properly formatted, only that it is non-empty. The name must not contain a
|
|
// left angle bracket, '<', and the email address must not contain a right
|
|
// angle bracket, '>'.
|
|
func ParsePatchIdentity(s string) (PatchIdentity, error) {
|
|
var emailStart, emailEnd int
|
|
for i, c := range s {
|
|
if c == '<' && emailStart == 0 {
|
|
emailStart = i + 1
|
|
}
|
|
if c == '>' && emailStart > 0 {
|
|
emailEnd = i
|
|
break
|
|
}
|
|
}
|
|
if emailStart > 0 && emailEnd == 0 {
|
|
return PatchIdentity{}, fmt.Errorf("invalid identity string: unclosed email section: %s", s)
|
|
}
|
|
|
|
var name, email string
|
|
if emailStart > 0 {
|
|
name = strings.TrimSpace(s[:emailStart-1])
|
|
}
|
|
if emailStart > 0 && emailEnd > 0 {
|
|
email = strings.TrimSpace(s[emailStart:emailEnd])
|
|
}
|
|
if name == "" || email == "" {
|
|
return PatchIdentity{}, fmt.Errorf("invalid identity string: %s", s)
|
|
}
|
|
|
|
return PatchIdentity{Name: name, Email: email}, nil
|
|
}
|
|
|
|
// ParsePatchDate parses a patch date string. It returns the parsed time or an
|
|
// error if s has an unknown format. ParsePatchDate supports the iso, rfc,
|
|
// short, raw, unix, and default formats (with local variants) used by the
|
|
// --date flag in Git.
|
|
func ParsePatchDate(s string) (time.Time, error) {
|
|
const (
|
|
isoFormat = "2006-01-02 15:04:05 -0700"
|
|
isoStrictFormat = "2006-01-02T15:04:05-07:00"
|
|
rfc2822Format = "Mon, 2 Jan 2006 15:04:05 -0700"
|
|
shortFormat = "2006-01-02"
|
|
defaultFormat = "Mon Jan 2 15:04:05 2006 -0700"
|
|
defaultLocalFormat = "Mon Jan 2 15:04:05 2006"
|
|
)
|
|
|
|
if s == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
|
|
for _, fmt := range []string{
|
|
isoFormat,
|
|
isoStrictFormat,
|
|
rfc2822Format,
|
|
shortFormat,
|
|
defaultFormat,
|
|
defaultLocalFormat,
|
|
} {
|
|
if t, err := time.ParseInLocation(fmt, s, time.Local); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
// unix format
|
|
if unix, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
return time.Unix(unix, 0), nil
|
|
}
|
|
|
|
// raw format
|
|
if space := strings.IndexByte(s, ' '); space > 0 {
|
|
unix, uerr := strconv.ParseInt(s[:space], 10, 64)
|
|
zone, zerr := time.Parse("-0700", s[space+1:])
|
|
if uerr == nil && zerr == nil {
|
|
return time.Unix(unix, 0).In(zone.Location()), nil
|
|
}
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("unknown date format: %s", s)
|
|
}
|
|
|
|
// ParsePatchHeader parses a preamble string as returned by Parse into a
|
|
// PatchHeader. Due to the variety of header formats, some fields of the parsed
|
|
// PatchHeader may be unset after parsing.
|
|
//
|
|
// Supported formats are the short, medium, full, fuller, and email pretty
|
|
// formats used by git diff, git log, and git show and the UNIX mailbox format
|
|
// used by git format-patch.
|
|
//
|
|
// If ParsePatchHeader detects that it is handling an email, it will
|
|
// remove extra content at the beginning of the title line, such as
|
|
// `[PATCH]` or `Re:` in the same way that `git mailinfo` does.
|
|
// SubjectPrefix will be set to the value of this removed string.
|
|
// (`git mailinfo` is the core part of `git am` that pulls information
|
|
// out of an individual mail.)
|
|
//
|
|
// Additionally, if ParsePatchHeader detects that it's handling an
|
|
// email, it will remove a `---` line and put anything after it into
|
|
// BodyAppendix.
|
|
//
|
|
// Those wishing the effect of a plain `git am` should use
|
|
// `PatchHeader.Title + "\n" + PatchHeader.Body` (or
|
|
// `PatchHeader.Message()`). Those wishing to retain the subject
|
|
// prefix and appendix material should use `PatchHeader.SubjectPrefix
|
|
// + PatchHeader.Title + "\n" + PatchHeader.Body + "\n" +
|
|
// PatchHeader.BodyAppendix`.
|
|
func ParsePatchHeader(s string) (*PatchHeader, error) {
|
|
r := bufio.NewReader(strings.NewReader(s))
|
|
|
|
var line string
|
|
for {
|
|
var err error
|
|
line, err = r.ReadString('\n')
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if len(line) > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, mailHeaderPrefix):
|
|
return parseHeaderMail(line, r)
|
|
case strings.HasPrefix(line, prettyHeaderPrefix):
|
|
return parseHeaderPretty(line, r)
|
|
}
|
|
return nil, errors.New("unrecognized patch header format")
|
|
}
|
|
|
|
func parseHeaderPretty(prettyLine string, r io.Reader) (*PatchHeader, error) {
|
|
const (
|
|
authorPrefix = "Author:"
|
|
commitPrefix = "Commit:"
|
|
datePrefix = "Date:"
|
|
authorDatePrefix = "AuthorDate:"
|
|
commitDatePrefix = "CommitDate:"
|
|
)
|
|
|
|
h := &PatchHeader{}
|
|
|
|
prettyLine = prettyLine[len(prettyHeaderPrefix):]
|
|
if i := strings.IndexByte(prettyLine, ' '); i > 0 {
|
|
h.SHA = prettyLine[:i]
|
|
} else {
|
|
h.SHA = prettyLine
|
|
}
|
|
|
|
s := bufio.NewScanner(r)
|
|
for s.Scan() {
|
|
line := s.Text()
|
|
|
|
// empty line marks end of fields, remaining lines are title/message
|
|
if strings.TrimSpace(line) == "" {
|
|
break
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, authorPrefix):
|
|
u, err := ParsePatchIdentity(line[len(authorPrefix):])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.Author = &u
|
|
|
|
case strings.HasPrefix(line, commitPrefix):
|
|
u, err := ParsePatchIdentity(line[len(commitPrefix):])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.Committer = &u
|
|
|
|
case strings.HasPrefix(line, datePrefix):
|
|
d, err := ParsePatchDate(strings.TrimSpace(line[len(datePrefix):]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.AuthorDate = d
|
|
|
|
case strings.HasPrefix(line, authorDatePrefix):
|
|
d, err := ParsePatchDate(strings.TrimSpace(line[len(authorDatePrefix):]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.AuthorDate = d
|
|
|
|
case strings.HasPrefix(line, commitDatePrefix):
|
|
d, err := ParsePatchDate(strings.TrimSpace(line[len(commitDatePrefix):]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.CommitterDate = d
|
|
}
|
|
}
|
|
if s.Err() != nil {
|
|
return nil, s.Err()
|
|
}
|
|
|
|
title, indent := scanMessageTitle(s)
|
|
if s.Err() != nil {
|
|
return nil, s.Err()
|
|
}
|
|
h.Title = title
|
|
|
|
if title != "" {
|
|
// Don't check for an appendix
|
|
body, _ := scanMessageBody(s, indent, false)
|
|
if s.Err() != nil {
|
|
return nil, s.Err()
|
|
}
|
|
h.Body = body
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
func scanMessageTitle(s *bufio.Scanner) (title string, indent string) {
|
|
var b strings.Builder
|
|
for i := 0; s.Scan(); i++ {
|
|
line := s.Text()
|
|
trimLine := strings.TrimSpace(line)
|
|
if trimLine == "" {
|
|
break
|
|
}
|
|
|
|
if i == 0 {
|
|
if start := strings.IndexFunc(line, func(c rune) bool { return !unicode.IsSpace(c) }); start > 0 {
|
|
indent = line[:start]
|
|
}
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(trimLine)
|
|
}
|
|
return b.String(), indent
|
|
}
|
|
|
|
func scanMessageBody(s *bufio.Scanner, indent string, separateAppendix bool) (string, string) {
|
|
// Body and appendix
|
|
var body, appendix strings.Builder
|
|
c := &body
|
|
var empty int
|
|
for i := 0; s.Scan(); i++ {
|
|
line := s.Text()
|
|
|
|
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
|
line = strings.TrimPrefix(line, indent)
|
|
|
|
if line == "" {
|
|
empty++
|
|
continue
|
|
}
|
|
|
|
// If requested, parse out "appendix" information (often added
|
|
// by `git format-patch` and removed by `git am`).
|
|
if separateAppendix && c == &body && line == "---" {
|
|
c = &appendix
|
|
continue
|
|
}
|
|
|
|
if c.Len() > 0 {
|
|
c.WriteByte('\n')
|
|
if empty > 0 {
|
|
c.WriteByte('\n')
|
|
}
|
|
}
|
|
empty = 0
|
|
|
|
c.WriteString(line)
|
|
}
|
|
return body.String(), appendix.String()
|
|
}
|
|
|
|
func parseHeaderMail(mailLine string, r io.Reader) (*PatchHeader, error) {
|
|
msg, err := mail.ReadMessage(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
h := &PatchHeader{}
|
|
|
|
mailLine = mailLine[len(mailHeaderPrefix):]
|
|
if i := strings.IndexByte(mailLine, ' '); i > 0 {
|
|
h.SHA = mailLine[:i]
|
|
}
|
|
|
|
addrs, err := msg.Header.AddressList("From")
|
|
if err != nil && !errors.Is(err, mail.ErrHeaderNotPresent) {
|
|
return nil, err
|
|
}
|
|
if len(addrs) > 0 {
|
|
addr := addrs[0]
|
|
if addr.Name == "" {
|
|
return nil, fmt.Errorf("invalid user string: %s", addr)
|
|
}
|
|
h.Author = &PatchIdentity{Name: addr.Name, Email: addr.Address}
|
|
}
|
|
|
|
date := msg.Header.Get("Date")
|
|
if date != "" {
|
|
d, err := ParsePatchDate(date)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.AuthorDate = d
|
|
}
|
|
|
|
subject := msg.Header.Get("Subject")
|
|
h.SubjectPrefix, h.Title = parseSubject(subject)
|
|
|
|
s := bufio.NewScanner(msg.Body)
|
|
h.Body, h.BodyAppendix = scanMessageBody(s, "", true)
|
|
if s.Err() != nil {
|
|
return nil, s.Err()
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// Takes an email subject and returns the patch prefix and commit
|
|
// title. i.e., `[PATCH v3 3/5] Implement foo` would return `[PATCH
|
|
// v3 3/5] ` and `Implement foo`
|
|
func parseSubject(s string) (string, string) {
|
|
// This is meant to be compatible with
|
|
// https://github.com/git/git/blob/master/mailinfo.c:cleanup_subject().
|
|
// If compatibility with `git am` drifts, go there to see if there
|
|
// are any updates.
|
|
|
|
at := 0
|
|
for at < len(s) {
|
|
switch s[at] {
|
|
case 'r', 'R':
|
|
// Detect re:, Re:, rE: and RE:
|
|
if at+2 < len(s) &&
|
|
(s[at+1] == 'e' || s[at+1] == 'E') &&
|
|
s[at+2] == ':' {
|
|
at += 3
|
|
continue
|
|
}
|
|
|
|
case ' ', '\t', ':':
|
|
// Delete whitespace and duplicate ':' characters
|
|
at++
|
|
continue
|
|
|
|
case '[':
|
|
// Look for closing parenthesis
|
|
j := at + 1
|
|
for ; j < len(s); j++ {
|
|
if s[j] == ']' {
|
|
break
|
|
}
|
|
}
|
|
|
|
if j < len(s) {
|
|
at = j + 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Only loop if we actually removed something
|
|
break
|
|
}
|
|
|
|
return s[:at], s[at:]
|
|
}
|