Skip to content

Commit ab5e88b

Browse files
committed
Add error type to mark conflicts during apply
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 ab5e88b

3 files changed

Lines changed: 233 additions & 9 deletions

File tree

applier.go

Lines changed: 32 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,8 @@ 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 *Conflict.
5154
func (a *Applier) Apply(ctx context.Context, f *gitdiff.File) (*github.TreeEntry, error) {
5255
// TODO(bkeyes): validate file to make sure fields are consistent
5356
// maybe two modes: validate and fix, where fix tries to set
@@ -91,10 +94,10 @@ func (a *Applier) applyCreate(ctx context.Context, f *gitdiff.File) (*github.Tre
9194
return nil, err
9295
}
9396
if exists {
94-
return nil, errors.New("existing entry for new file")
97+
return nil, &Conflict{Type: ConflictNewFileExists, File: f.NewName}
9598
}
9699

97-
c, err := base64Apply(nil, f)
100+
c, err := base64Apply(nil, f.NewName, f)
98101
if err != nil {
99102
return nil, err
100103
}
@@ -117,17 +120,17 @@ func (a *Applier) applyDelete(ctx context.Context, f *gitdiff.File) (*github.Tre
117120
return nil, err
118121
}
119122
if !exists {
120-
// because the rest of application is strict, return an error if the
123+
// Because the rest of application is strict, return an error if the
121124
// file was already deleted, since it indicates a conflict of some kind
122-
return nil, errors.New("missing entry for deleted file")
125+
return nil, &Conflict{Type: ConflictDeletedFileMissing, File: f.OldName}
123126
}
124127

125128
data, _, err := a.client.Git.GetBlobRaw(ctx, a.owner, a.repo, entry.GetSHA())
126129
if err != nil {
127130
return nil, fmt.Errorf("get blob content failed: %w", err)
128131
}
129132

130-
if err := gitdiff.Apply(ioutil.Discard, bytes.NewReader(data), f); err != nil {
133+
if err := apply(ioutil.Discard, bytes.NewReader(data), f.OldName, f); err != nil {
131134
return nil, err
132135
}
133136

@@ -147,7 +150,7 @@ func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) (*github.Tre
147150
return nil, err
148151
}
149152
if !exists {
150-
return nil, errors.New("no entry for modified file")
153+
return nil, &Conflict{Type: ConflictModifiedFileMissing, File: f.OldName}
151154
}
152155

153156
path := f.NewName
@@ -163,7 +166,7 @@ func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) (*github.Tre
163166
return nil, fmt.Errorf("get blob content failed: %w", err)
164167
}
165168

166-
c, err := base64Apply(data, f)
169+
c, err := base64Apply(data, f.OldName, f)
167170
if err != nil {
168171
return nil, err
169172
}
@@ -341,11 +344,13 @@ func findTreeEntry(t *github.Tree, name, entryType string) (*github.TreeEntry, b
341344
return nil, false
342345
}
343346

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

347352
enc := base64.NewEncoder(base64.StdEncoding, &b)
348-
if err := gitdiff.Apply(enc, bytes.NewReader(data), f); err != nil {
353+
if err := apply(enc, bytes.NewReader(data), name, f); err != nil {
349354
return "", err
350355
}
351356
if err := enc.Close(); err != nil {
@@ -355,6 +360,24 @@ func base64Apply(data []byte, f *gitdiff.File) (string, error) {
355360
return b.String(), nil
356361
}
357362

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

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+
}

0 commit comments

Comments
 (0)