-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmailpatch.go
More file actions
203 lines (181 loc) · 6.08 KB
/
mailpatch.go
File metadata and controls
203 lines (181 loc) · 6.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// Package mailpatch parses git "format-patch" emails into structured data.
//
// `git format-patch` turns commits into RFC 5322 email messages: the commit
// subject becomes the mail Subject (prefixed with "[PATCH n/m]"), the author
// and date become headers, the commit message becomes the body, and the diff
// follows after a "---" separator and a diffstat. `git send-email` mails those
// out; reviewers reply, and maintainers feed them back to `git am`.
//
// mailpatch reads one of those messages — or a whole mbox of them — and gives
// you the pieces without shelling out to git:
//
// - Parse / ParseBytes decode a single message into a Patch: author, date,
// cleaned subject, [PATCH n/m] series position, commit message body, and
// the parsed diff (per-file hunks plus a diffstat).
// - ParseMbox splits an mbox into one Patch per message.
// - ParseSeries groups an mbox into a Series: the cover letter (the "0/n"
// message) plus the numbered patches in order.
// - ParseDiff parses a bare unified diff on its own, no email envelope.
//
// It depends only on the standard library and never executes git.
package mailpatch
import (
"bytes"
"errors"
"io"
"mime"
"net/mail"
"strings"
"time"
)
// Sentinel errors. Compare with errors.Is.
var (
// ErrEmpty is returned when the input has no message at all.
ErrEmpty = errors.New("mailpatch: empty input")
// ErrMalformed is returned when the input is not a parseable RFC 5322
// message (bad headers, truncated mid-header, and similar).
ErrMalformed = errors.New("mailpatch: malformed message")
)
// Patch is a single parsed format-patch email.
//
// A message that carries no diff — most often a "0/n" cover letter — still
// parses into a Patch; its Diff is empty and HasDiff reports false.
type Patch struct {
// From is the raw From header (decoded from any RFC 2047 encoding).
From string
// AuthorName and AuthorEmail are From split into its parts, best effort.
AuthorName string
AuthorEmail string
// Date is the parsed Date header; the zero time if it was absent or
// unparseable.
Date time.Time
// Subject is the subject with any "[PATCH ...]" prefix stripped.
Subject string
// RawSubject is the original, undecoded-prefix subject line.
RawSubject string
// MessageID, InReplyTo and References come from the corresponding headers
// (angle brackets stripped). They thread a series together.
MessageID string
InReplyTo string
References []string
// Series is the position parsed from the subject prefix.
Series SeriesInfo
// Body is the commit message: everything between the headers and the
// diffstat/diff separator.
Body string
// Diff is the raw unified diff text, signature stripped. Empty for a
// cover letter.
Diff string
// Files is Diff parsed into per-file changes.
Files []FileChange
// Stat is the diffstat computed from Files.
Stat DiffStat
// Header is the full set of decoded message headers, for callers that
// need a field this struct does not surface.
Header mail.Header
}
// HasDiff reports whether the message carried an actual diff.
func (p *Patch) HasDiff() bool { return p.Diff != "" }
// IsCoverLetter reports whether this is a series cover letter: a "0/n" subject
// prefix, or simply a patch mail with no diff.
func (p *Patch) IsCoverLetter() bool {
return p.Series.IsCover || (!p.HasDiff() && p.Series.Total > 0)
}
// SeriesInfo is the position of a patch within a series, parsed from the
// "[PATCH n/m]" (or "[RFC PATCH v2 n/m]") subject prefix.
type SeriesInfo struct {
// Index is n in "[PATCH n/m]"; 0 for a cover letter or a lone patch with
// no "n/m".
Index int
// Total is m in "[PATCH n/m]"; 0 when the subject had no count.
Total int
// Version is the revision: 2 for "[PATCH v2 ...]", 1 when unspecified.
Version int
// Prefix is the prefix words other than the version and count, e.g.
// "PATCH" or "RFC PATCH".
Prefix string
// IsCover is true for the "0/m" message.
IsCover bool
}
// Parse decodes a single format-patch email from r.
func Parse(r io.Reader) (*Patch, error) {
if r == nil {
return nil, ErrEmpty
}
msg, err := mail.ReadMessage(r)
if err != nil {
if errors.Is(err, io.EOF) {
return nil, ErrEmpty
}
return nil, errors.Join(ErrMalformed, err)
}
body, err := extractText(msg.Header, msg.Body)
if err != nil {
return nil, err
}
p := &Patch{Header: msg.Header}
p.From = decodeHeader(msg.Header.Get("From"))
p.AuthorName, p.AuthorEmail = splitAddress(p.From)
if t, err := msg.Header.Date(); err == nil {
p.Date = t
}
p.MessageID = trimAngles(msg.Header.Get("Message-ID"))
p.InReplyTo = trimAngles(msg.Header.Get("In-Reply-To"))
p.References = splitRefs(msg.Header.Get("References"))
p.RawSubject = decodeHeader(msg.Header.Get("Subject"))
p.Subject, p.Series = parseSubject(p.RawSubject)
p.Body, p.Diff = splitBodyDiff(string(body))
if p.Diff != "" {
p.Files, _ = ParseDiff(p.Diff)
p.Stat = statOf(p.Files)
}
return p, nil
}
// ParseBytes is Parse over an in-memory message.
func ParseBytes(b []byte) (*Patch, error) {
return Parse(bytes.NewReader(b))
}
// decodeHeader decodes any RFC 2047 encoded-words in a header value.
func decodeHeader(v string) string {
if v == "" {
return ""
}
dec := mime.WordDecoder{}
if out, err := dec.DecodeHeader(v); err == nil {
return out
}
return v
}
func trimAngles(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "<")
s = strings.TrimSuffix(s, ">")
return s
}
func splitRefs(s string) []string {
fields := strings.Fields(s)
if len(fields) == 0 {
return nil
}
out := make([]string, 0, len(fields))
for _, f := range fields {
out = append(out, trimAngles(f))
}
return out
}
// splitAddress splits a From value into display name and email, best effort.
func splitAddress(from string) (name, email string) {
if from == "" {
return "", ""
}
if addr, err := mail.ParseAddress(from); err == nil {
return addr.Name, addr.Address
}
// Fall back to a bare "Name <addr>" or "addr" split.
if i := strings.LastIndexByte(from, '<'); i >= 0 {
name = strings.TrimSpace(from[:i])
email = trimAngles(from[i:])
return name, email
}
return "", strings.TrimSpace(from)
}