Skip to content

Commit a89561b

Browse files
authored
Support reading multiple patches from an mbox file (#89)
Using a combined mbox file may be more convenient than providing multiple files or running the command multiple times, depending on the source of the patches. Note that this doesn't do any unescaping of escaped "From " lines in the message content. This isn't a problem for the actual patch content, but could lead to unexpected commit messages.
1 parent 7c84ab0 commit a89561b

File tree

7 files changed

+355
-28
lines changed

7 files changed

+355
-28
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ Usage: patch2pr [options] [patch...]
4949
5050
Create a GitHub pull request from a patch file
5151
52-
This command parses a patch, applies it, and creates a pull request with the
53-
result. It does not clone the repository to apply the patch. If no patch file
54-
is given, the command reads the patch from standard input.
52+
This command parses one or more patches, applies them, and creates a pull
53+
request with the result. It does not clone the repository. If no patch files
54+
are given, the command reads the patches from standard input. Each file can
55+
contain a single patch or multiple patches in the mbox format produced by 'git
56+
format-patch --stdout' or GitHub's patch view.
5557
5658
By default, patch2pr uses the patch header for author and committer
5759
information, falling back to the authenticated GitHub user if the headers are

cmd/patch2pr/main.go

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,9 @@ func main() {
148148
}
149149

150150
type Patch struct {
151-
path string
152-
files []*gitdiff.File
153-
preamble string
154-
header *gitdiff.PatchHeader
151+
path string
152+
files []*gitdiff.File
153+
header *gitdiff.PatchHeader
155154
}
156155

157156
type Result struct {
@@ -165,7 +164,7 @@ type PullRequestResult struct {
165164
URL string `json:"url"`
166165
}
167166

168-
func parse(patchFile string) (*Patch, error) {
167+
func parse(patchFile string) ([]Patch, error) {
169168
var r io.ReadCloser
170169
if patchFile == "-" {
171170
r = os.Stdin
@@ -176,22 +175,28 @@ func parse(patchFile string) (*Patch, error) {
176175
}
177176
r = f
178177
}
178+
defer closeQuitely(r)
179179

180-
files, preamble, err := gitdiff.Parse(r)
181-
if err != nil {
182-
return nil, fmt.Errorf("parsing patch failed: %w", err)
183-
}
184-
_ = r.Close()
180+
mbr := mboxMessageReader{r: r}
185181

186-
var header *gitdiff.PatchHeader
187-
if len(preamble) > 0 {
188-
header, err = gitdiff.ParsePatchHeader(preamble)
182+
var patches []Patch
183+
for mbr.Next() {
184+
files, preamble, err := gitdiff.Parse(&mbr)
189185
if err != nil {
190-
fmt.Fprintf(os.Stderr, "warning: invalid patch header: %v", err)
186+
return nil, fmt.Errorf("parsing patch failed: %w", err)
187+
}
188+
189+
var header *gitdiff.PatchHeader
190+
if len(preamble) > 0 {
191+
header, err = gitdiff.ParsePatchHeader(preamble)
192+
if err != nil {
193+
fmt.Fprintf(os.Stderr, "warning: invalid patch header: %v", err)
194+
}
191195
}
192-
}
193196

194-
return &Patch{patchFile, files, preamble, header}, nil
197+
patches = append(patches, Patch{patchFile, files, header})
198+
}
199+
return patches, nil
195200
}
196201

197202
func execute(ctx context.Context, client *github.Client, patchFiles []string, opts *Options) (*Result, error) {
@@ -224,23 +229,24 @@ func execute(ctx context.Context, client *github.Client, patchFiles []string, op
224229
return nil, fmt.Errorf("get commit for %s failed: %w", patchBase, err)
225230
}
226231

227-
var patches []Patch
232+
var allPatches []Patch
228233
for _, patchFile := range patchFiles {
229-
patch, err := parse(patchFile)
234+
patches, err := parse(patchFile)
230235
if err != nil {
231236
return nil, err
232237
}
233-
patches = append(patches, *patch)
238+
allPatches = append(allPatches, patches...)
234239
}
235240

236241
sourceRepo, err := prepareSourceRepo(ctx, client, opts)
237242
if err != nil {
238243
return nil, err
239244
}
240245

241-
newCommit := commit
242-
for _, patch := range patches {
243-
applier := patch2pr.NewApplier(client, sourceRepo, newCommit)
246+
applier := patch2pr.NewApplier(client, sourceRepo, commit)
247+
248+
var newCommit *github.Commit
249+
for _, patch := range allPatches {
244250
for _, file := range patch.files {
245251
if _, err := applier.Apply(ctx, file); err != nil {
246252
name := file.NewName
@@ -479,15 +485,21 @@ func isCode(err error, code int) bool {
479485
return errors.As(err, &rerr) && rerr.Response.StatusCode == code
480486
}
481487

488+
func closeQuitely(c io.Closer) {
489+
_ = c.Close()
490+
}
491+
482492
func helpText() string {
483493
help := `
484494
Usage: patch2pr [options] [patch...]
485495
486496
Create a GitHub pull request from a patch file
487497
488-
This command parses a patch, applies it, and creates a pull request with the
489-
result. It does not clone the repository to apply the patch. If no patch file
490-
is given, the command reads the patch from standard input.
498+
This command parses one or more patches, applies them, and creates a pull
499+
request with the result. It does not clone the repository. If no patch files
500+
are given, the command reads the patches from standard input. Each file can
501+
contain a single patch or multiple patches in the mbox format produced by 'git
502+
format-patch --stdout' or GitHub's patch view.
491503
492504
By default, patch2pr uses the patch header for author and committer
493505
information, falling back to the authenticated GitHub user if the headers are

cmd/patch2pr/mbox.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"io"
6+
)
7+
8+
var (
9+
mboxHeader = []byte("From ")
10+
)
11+
12+
type fileType int
13+
14+
const (
15+
fileUnknown fileType = iota
16+
filePlain
17+
fileMBox
18+
)
19+
20+
type mboxMessageReader struct {
21+
r io.Reader
22+
next bytes.Buffer
23+
24+
headers int
25+
ftype fileType
26+
27+
isLineStart bool
28+
isEOF bool
29+
}
30+
31+
func (r *mboxMessageReader) Next() bool {
32+
if r.isEOF && r.bufferEmpty() {
33+
return false
34+
}
35+
36+
r.headers = 0
37+
r.isLineStart = true
38+
return true
39+
}
40+
41+
func (r *mboxMessageReader) Read(p []byte) (n int, err error) {
42+
// TODO(bkeyes): This is broken if it is only called with len(p) < len(mboxHeader).
43+
// This shouldn't be a problem in practice because go-gitdiff always wraps
44+
// the input reader in a bufio.Reader, which uses a large enough buffer.
45+
46+
if len(p) == 0 {
47+
return 0, nil
48+
}
49+
if r.headers > 1 || (r.isEOF && r.bufferEmpty()) {
50+
return 0, io.EOF
51+
}
52+
53+
if !r.bufferEmpty() {
54+
n, _ = r.next.Read(p)
55+
if r.bufferEmpty() {
56+
r.next.Reset()
57+
}
58+
}
59+
if n < len(p) {
60+
var nn int
61+
nn, err = r.r.Read(p[n:])
62+
if err == io.EOF {
63+
r.isEOF = true
64+
err = nil
65+
}
66+
n += nn
67+
}
68+
69+
if r.ftype == fileUnknown || r.ftype == fileMBox {
70+
n = r.scanForHeader(p, n)
71+
}
72+
73+
return n, err
74+
}
75+
76+
func (r *mboxMessageReader) scanForHeader(p []byte, n int) int {
77+
for i := 0; i < n; i++ {
78+
if isSpace(p[i]) {
79+
r.isLineStart = p[i] == '\n'
80+
continue
81+
}
82+
83+
if r.isLineStart {
84+
if matchLen := matchMBoxHeader(p[i:n]); matchLen > 0 {
85+
if matchLen == len(mboxHeader) {
86+
r.ftype = fileMBox
87+
r.headers++
88+
}
89+
if r.headers > 1 || matchLen < len(mboxHeader) {
90+
r.next.Write(p[i:n])
91+
return i
92+
}
93+
}
94+
}
95+
96+
if r.ftype == fileUnknown {
97+
r.ftype = filePlain
98+
break
99+
}
100+
}
101+
return n
102+
}
103+
104+
func (r *mboxMessageReader) bufferEmpty() bool {
105+
return r.next.Len() == 0
106+
}
107+
108+
func matchMBoxHeader(b []byte) (n int) {
109+
for n < len(b) && n < len(mboxHeader) {
110+
if b[n] != mboxHeader[n] {
111+
return 0
112+
}
113+
n++
114+
}
115+
return n
116+
}
117+
118+
func isSpace(b byte) bool {
119+
switch b {
120+
case ' ', '\t', '\r', '\n':
121+
return true
122+
}
123+
return false
124+
}

cmd/patch2pr/mbox_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"os"
6+
"strconv"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestMBoxMessageReader(t *testing.T) {
12+
plainMessage, err := os.ReadFile("testdata/test.txt")
13+
if err != nil {
14+
t.Fatalf("error reading file: %v", err)
15+
}
16+
17+
tests := map[string]struct {
18+
File string
19+
Count int
20+
ExpectedContent map[int]string
21+
}{
22+
"mbox": {
23+
File: "testdata/test.mbox",
24+
Count: 5,
25+
ExpectedContent: map[int]string{
26+
0: `From 5255ca3071e33871ad7c23de1a3962f19b215f74 Mon Sep 17 00:00:00 2001
27+
This is the first message
28+
29+
It has several
30+
lines
31+
32+
`,
33+
1: `From 5a900b1a1f8b3e4244127bff85a5fd2d82ae2ced Mon Sep 17 00:00:00 2001
34+
From: Test <[email protected]>
35+
This is the second message
36+
37+
It has an mbox From header in the middle of a line and also has an email From: header
38+
39+
`,
40+
2: `From e32a4a21b36ccee78e91aa13933388a976bbd9da Mon Sep 17 00:00:00 2001
41+
42+
`,
43+
4: `From fdc67b11c12a7cdc7c9714b3b8ef95746198ee40 Mon Sep 17 00:00:00 2001
44+
45+
This is the last message in the file.
46+
`,
47+
},
48+
},
49+
"short": {
50+
File: "testdata/short.mbox",
51+
Count: 3,
52+
},
53+
"plain": {
54+
File: "testdata/test.txt",
55+
Count: 1,
56+
ExpectedContent: map[int]string{0: string(plainMessage)},
57+
},
58+
}
59+
60+
for name, test := range tests {
61+
t.Run(name, func(t *testing.T) {
62+
f, err := os.Open(test.File)
63+
if err != nil {
64+
t.Fatalf("error opening file: %v", err)
65+
}
66+
defer f.Close()
67+
68+
mbr := mboxMessageReader{r: f}
69+
70+
var msgs []string
71+
for i := 0; mbr.Next(); i++ {
72+
b, err := io.ReadAll(&mbr)
73+
if err != nil {
74+
t.Fatalf("unexpected error reading message %d: %v", i+1, err)
75+
}
76+
msgs = append(msgs, string(b))
77+
}
78+
79+
assertMsgCount(t, msgs, test.Count)
80+
for key, msg := range test.ExpectedContent {
81+
assertMsgContent(t, msgs, key, msg)
82+
}
83+
})
84+
}
85+
}
86+
87+
func assertMsgCount(t *testing.T, msgs []string, count int) {
88+
if len(msgs) != count {
89+
msgStrs := make([]string, len(msgs))
90+
for i, m := range msgs {
91+
msgStrs[i] = " " + strconv.Quote(m)
92+
}
93+
94+
t.Fatalf("incorrect number of messages: expected %d, got %d\nmessages: [\n%s,\n]", count, len(msgs), strings.Join(msgStrs, ",\n"))
95+
}
96+
}
97+
98+
func assertMsgContent(t *testing.T, msgs []string, i int, expected string) {
99+
if msgs[i] != expected {
100+
t.Errorf("incorrect content for message %d:\nexpected: %q\n actual: %q", i+1, expected, msgs[i])
101+
}
102+
}

cmd/patch2pr/testdata/short.mbox

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
From 1b3774094595c5a89650fa56c9cf9af940656874 Mon Sep 17 00:00:00 2001
2+
3+
From 0baa2dbec1da4405bb149c0e252f0dbfe37f36d7 Mon Sep 17 00:00:00 2001
4+
5+
From 66497dd445fb80da4dbda32c1eef8008ddb8643e Mon Sep 17 00:00:00 2001

0 commit comments

Comments
 (0)