Skip to content

Commit c7d8914

Browse files
authored
Add error type to mark conflicts during apply (#166)
Apply now returns an error with type *Conflict if the error is because of a conflict between the patch and the target. This allows callers to respond to conflicts differently from other errors.
1 parent a508833 commit c7d8914

5 files changed

Lines changed: 252 additions & 21 deletions

File tree

applier.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/base64"
88
"errors"
99
"fmt"
10+
"io"
1011
"io/ioutil"
1112
"strconv"
1213
"strings"
@@ -48,6 +49,9 @@ func NewApplier(client *github.Client, repo Repository, c *github.Commit) *Appli
4849
// Apply applies the changes in a file, adds the result to the list of pending
4950
// tree entries, and returns the entry. If the application succeeds, Apply
5051
// creates a blob in the repository with the modified content.
52+
//
53+
// If the apply fails due to a conflict, Apply returns an error of type
54+
// *Conflict.
5155
func (a *Applier) Apply(ctx context.Context, f *gitdiff.File) (*github.TreeEntry, error) {
5256
// TODO(bkeyes): validate file to make sure fields are consistent
5357
// maybe two modes: validate and fix, where fix tries to set
@@ -91,10 +95,10 @@ func (a *Applier) applyCreate(ctx context.Context, f *gitdiff.File) (*github.Tre
9195
return nil, err
9296
}
9397
if exists {
94-
return nil, errors.New("existing entry for new file")
98+
return nil, &Conflict{Type: ConflictNewFileExists, File: f.NewName}
9599
}
96100

97-
c, err := base64Apply(nil, f)
101+
c, err := base64Apply(nil, f.NewName, f)
98102
if err != nil {
99103
return nil, err
100104
}
@@ -117,17 +121,17 @@ func (a *Applier) applyDelete(ctx context.Context, f *gitdiff.File) (*github.Tre
117121
return nil, err
118122
}
119123
if !exists {
120-
// because the rest of application is strict, return an error if the
124+
// Because the rest of application is strict, return an error if the
121125
// file was already deleted, since it indicates a conflict of some kind
122-
return nil, errors.New("missing entry for deleted file")
126+
return nil, &Conflict{Type: ConflictDeletedFileMissing, File: f.OldName}
123127
}
124128

125129
data, _, err := a.client.Git.GetBlobRaw(ctx, a.owner, a.repo, entry.GetSHA())
126130
if err != nil {
127131
return nil, fmt.Errorf("get blob content failed: %w", err)
128132
}
129133

130-
if err := gitdiff.Apply(ioutil.Discard, bytes.NewReader(data), f); err != nil {
134+
if err := apply(ioutil.Discard, bytes.NewReader(data), f.OldName, f); err != nil {
131135
return nil, err
132136
}
133137

@@ -147,7 +151,7 @@ func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) (*github.Tre
147151
return nil, err
148152
}
149153
if !exists {
150-
return nil, errors.New("no entry for modified file")
154+
return nil, &Conflict{Type: ConflictModifiedFileMissing, File: f.OldName}
151155
}
152156

153157
path := f.NewName
@@ -163,7 +167,7 @@ func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) (*github.Tre
163167
return nil, fmt.Errorf("get blob content failed: %w", err)
164168
}
165169

166-
c, err := base64Apply(data, f)
170+
c, err := base64Apply(data, f.OldName, f)
167171
if err != nil {
168172
return nil, err
169173
}
@@ -341,11 +345,13 @@ func findTreeEntry(t *github.Tree, name, entryType string) (*github.TreeEntry, b
341345
return nil, false
342346
}
343347

344-
func base64Apply(data []byte, f *gitdiff.File) (string, error) {
348+
// base64Apply applies the patch in f to data and returns the result as a
349+
// base64-encoded string.
350+
func base64Apply(data []byte, name string, f *gitdiff.File) (string, error) {
345351
var b bytes.Buffer
346352

347353
enc := base64.NewEncoder(base64.StdEncoding, &b)
348-
if err := gitdiff.Apply(enc, bytes.NewReader(data), f); err != nil {
354+
if err := apply(enc, bytes.NewReader(data), name, f); err != nil {
349355
return "", err
350356
}
351357
if err := enc.Close(); err != nil {
@@ -355,6 +361,24 @@ func base64Apply(data []byte, f *gitdiff.File) (string, error) {
355361
return b.String(), nil
356362
}
357363

364+
// apply runs gitdiff.Apply, wrapping any conflicts in patch2pr's Conflict type.
365+
func apply(dst io.Writer, src io.ReaderAt, name string, f *gitdiff.File) error {
366+
if err := gitdiff.Apply(dst, src, f); err != nil {
367+
var applyErr *gitdiff.ApplyError
368+
var conflict *gitdiff.Conflict
369+
if errors.As(err, &applyErr) && errors.As(err, &conflict) {
370+
return &Conflict{
371+
Type: ConflictContent,
372+
File: name,
373+
Line: applyErr.Line,
374+
cause: conflict,
375+
}
376+
}
377+
return err
378+
}
379+
return nil
380+
}
381+
358382
// TODO(bkeyes): extract this to go-gitdiff in some form?
359383
func getMode(f *gitdiff.File, existing *github.TreeEntry) string {
360384
switch {

cmd/patch2pr/main.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,15 @@ func execute(ctx context.Context, client *github.Client, patchFiles []string, op
249249
for _, patch := range allPatches {
250250
for _, file := range patch.files {
251251
if _, err := applier.Apply(ctx, file); err != nil {
252-
name := file.NewName
253-
if name == "" {
254-
name = file.OldName
252+
var namePart string
253+
if !errors.Is(err, &patch2pr.Conflict{}) {
254+
name := file.NewName
255+
if name == "" {
256+
name = file.OldName
257+
}
258+
namePart = name + ": "
255259
}
256-
return nil, fmt.Errorf("apply failed: %s: %w", name, err)
260+
return nil, fmt.Errorf("apply failed: %s%w", namePart, err)
257261
}
258262
}
259263

conflict.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package patch2pr
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8+
)
9+
10+
// Conflict is the error returned when applying a patch fails because of a
11+
// conflict between the patch and the target.
12+
type Conflict struct {
13+
// The type of the conflict.
14+
Type ConflictType
15+
// The path to the file in which the conflict occurs.
16+
File string
17+
// The line number of the conflict, if known.
18+
Line int64
19+
20+
cause *gitdiff.Conflict
21+
}
22+
23+
// ConflictType identifies the type of conflict.
24+
type ConflictType int
25+
26+
const (
27+
// ConflictUnspecified indicates the cause of the conflict was not specified.
28+
ConflictUnspecified ConflictType = iota
29+
30+
// ConflictNewFileExists indicates the patch creates a new file that already exists.
31+
ConflictNewFileExists
32+
33+
// ConflictDeletedFileMissing indicates the patch deletes a file that does not exist.
34+
ConflictDeletedFileMissing
35+
36+
// ConflictModifiedFileMissing indicates the patch modifies a file that does not exist.
37+
ConflictModifiedFileMissing
38+
39+
// ConflictContent indicates the patch content does not apply cleanly against the file's content.
40+
ConflictContent
41+
)
42+
43+
func (c *Conflict) Error() string {
44+
var msg strings.Builder
45+
if c.File != "" {
46+
msg.WriteString(c.File)
47+
if c.Line > 0 {
48+
fmt.Fprintf(&msg, ":%d", c.Line)
49+
}
50+
msg.WriteString(": ")
51+
}
52+
53+
switch c.Type {
54+
case ConflictNewFileExists:
55+
msg.WriteString("conflict: new file already exists")
56+
case ConflictDeletedFileMissing:
57+
msg.WriteString("conflict: deleted file does not exist")
58+
case ConflictModifiedFileMissing:
59+
msg.WriteString("conflict: modified file does not exist")
60+
case ConflictContent:
61+
if c.cause != nil {
62+
msg.WriteString(c.cause.Error())
63+
} else {
64+
msg.WriteString("conflict: content")
65+
}
66+
default:
67+
msg.WriteString("conflict")
68+
}
69+
70+
return msg.String()
71+
}
72+
73+
// Is returns true if all of the non-zero fields of target equal the values of
74+
// this Conflict. Passing an empty *Conflict{} always returns true.
75+
func (c *Conflict) Is(target error) bool {
76+
if other, ok := target.(*Conflict); ok {
77+
if other.Type == ConflictUnspecified {
78+
if other.File == "" {
79+
return true
80+
}
81+
return c.File == other.File
82+
}
83+
if other.File == "" {
84+
return c.Type == other.Type
85+
}
86+
return other.Type == c.Type && other.File == c.File
87+
}
88+
return false
89+
}
90+
91+
func (c *Conflict) Unwrap() error {
92+
return c.cause
93+
}

conflict_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package patch2pr
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestConflictError(t *testing.T) {
9+
for i, tc := range []struct {
10+
Conflict Conflict
11+
Error string
12+
}{
13+
{
14+
Conflict{},
15+
"conflict",
16+
},
17+
{
18+
Conflict{File: "path/to/file.txt"},
19+
"path/to/file.txt: conflict",
20+
},
21+
{
22+
Conflict{File: "path/to/file.txt", Type: ConflictNewFileExists},
23+
"path/to/file.txt: conflict: new file already exists",
24+
},
25+
{
26+
Conflict{File: "path/to/file.txt", Type: ConflictDeletedFileMissing},
27+
"path/to/file.txt: conflict: deleted file does not exist",
28+
},
29+
{
30+
Conflict{File: "path/to/file.txt", Type: ConflictModifiedFileMissing},
31+
"path/to/file.txt: conflict: modified file does not exist",
32+
},
33+
{
34+
Conflict{File: "path/to/file.txt", Type: ConflictContent},
35+
"path/to/file.txt: conflict: content",
36+
},
37+
{
38+
Conflict{File: "path/to/file.txt", Line: 23, Type: ConflictContent},
39+
"path/to/file.txt:23: conflict: content",
40+
},
41+
} {
42+
want := tc.Error
43+
if got := tc.Conflict.Error(); got != want {
44+
t.Errorf("case %d: Error(): want %q, got %q", i, want, got)
45+
}
46+
}
47+
}
48+
49+
func TestConflictIs(t *testing.T) {
50+
defaultConflict := Conflict{
51+
Type: ConflictModifiedFileMissing,
52+
File: "path/to/file.txt",
53+
}
54+
55+
for name, tc := range map[string]struct {
56+
Conflict Conflict
57+
Target error
58+
Match bool
59+
}{
60+
"nil": {
61+
Conflict: defaultConflict,
62+
Target: nil,
63+
Match: false,
64+
},
65+
"otherType": {
66+
Conflict: defaultConflict,
67+
Target: errors.New("different error"),
68+
Match: false,
69+
},
70+
"emptyConflictMatches": {
71+
Conflict: defaultConflict,
72+
Target: &Conflict{},
73+
Match: true,
74+
},
75+
"typeOnlyMatch": {
76+
Conflict: defaultConflict,
77+
Target: &Conflict{Type: ConflictModifiedFileMissing},
78+
Match: true,
79+
},
80+
"fileOnlyMatch": {
81+
Conflict: defaultConflict,
82+
Target: &Conflict{File: "path/to/file.txt"},
83+
Match: true,
84+
},
85+
"typeAndFileMatch": {
86+
Conflict: defaultConflict,
87+
Target: &Conflict{Type: ConflictModifiedFileMissing, File: "path/to/file.txt"},
88+
Match: true,
89+
},
90+
"differentType": {
91+
Conflict: defaultConflict,
92+
Target: &Conflict{Type: ConflictContent},
93+
Match: false,
94+
},
95+
"differentFile": {
96+
Conflict: defaultConflict,
97+
Target: &Conflict{File: "path/to/other/file.txt"},
98+
Match: false,
99+
},
100+
} {
101+
want := tc.Match
102+
t.Run(name, func(t *testing.T) {
103+
if got := tc.Conflict.Is(tc.Target); got != want {
104+
t.Errorf("Is(target): want %t, got %t", want, got)
105+
}
106+
})
107+
}
108+
}

graphql_applier.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bytes"
55
"context"
66
"encoding/base64"
7-
"errors"
87
"fmt"
98
"io/ioutil"
109
"os"
@@ -81,6 +80,9 @@ func (a *GraphQLApplier) SetV3Client(client *github.Client) {
8180
// When given an unsupported patch, Apply returns an error such that
8281
// IsUnsupported(err) is true. Setting a V3 client with SetV3Client allows
8382
// Apply to process some patches that are otherwise unsupported.
83+
//
84+
// If the apply fails due to a conflict, Apply returns an error of type
85+
// *Conflict.
8486
func (a *GraphQLApplier) Apply(ctx context.Context, f *gitdiff.File) error {
8587
// As of 2021-09-22, createCommitOnBranch handles file modes
8688
// inconsistently:
@@ -122,11 +124,11 @@ func (a *GraphQLApplier) applyCreate(ctx context.Context, f *gitdiff.File) error
122124
return err
123125
}
124126
if exists {
125-
return errors.New("existing entry for new file")
127+
return &Conflict{Type: ConflictNewFileExists, File: f.NewName}
126128
}
127129

128130
var b bytes.Buffer
129-
if err := gitdiff.Apply(&b, bytes.NewReader(nil), f); err != nil {
131+
if err := apply(&b, bytes.NewReader(nil), f.NewName, f); err != nil {
130132
return err
131133
}
132134

@@ -141,12 +143,12 @@ func (a *GraphQLApplier) applyDelete(ctx context.Context, f *gitdiff.File) error
141143
return err
142144
}
143145
if !exists {
144-
// because the rest of application is strict, return an error if the
146+
// Because the rest of application is strict, return an error if the
145147
// file was already deleted, since it indicates a conflict of some kind
146-
return errors.New("missing entry for deleted file")
148+
return &Conflict{Type: ConflictDeletedFileMissing, File: f.OldName}
147149
}
148150

149-
if err := gitdiff.Apply(ioutil.Discard, bytes.NewReader(data), f); err != nil {
151+
if err := apply(ioutil.Discard, bytes.NewReader(data), f.OldName, f); err != nil {
150152
return err
151153
}
152154

@@ -160,12 +162,12 @@ func (a *GraphQLApplier) applyModify(ctx context.Context, f *gitdiff.File) error
160162
return err
161163
}
162164
if !exists {
163-
return errors.New("no entry for modified file")
165+
return &Conflict{Type: ConflictModifiedFileMissing, File: f.OldName}
164166
}
165167

166168
if len(f.TextFragments) > 0 || f.BinaryFragment != nil {
167169
var b bytes.Buffer
168-
if err := gitdiff.Apply(&b, bytes.NewReader(data), f); err != nil {
170+
if err := apply(&b, bytes.NewReader(data), f.OldName, f); err != nil {
169171
return err
170172
}
171173
data = b.Bytes()

0 commit comments

Comments
 (0)