Skip to content

Commit 1a4881c

Browse files
authored
Allow multiple changes to the same file (#4)
Refactor the entry list to track entries by path and return an existing pending entry if one exists. This allows patches to contain multiple changes to the same file. The final entry will match the final state of the file. Note that we create blobs for all intermediate states, but GitHub should GC these. If the extra API requests or garbage become problems later on, we could avoid them by storing all modified content in memory and only creating blobs at the end, right before creating a tree.
1 parent 6cbbe1c commit 1a4881c

File tree

7 files changed

+139
-55
lines changed

7 files changed

+139
-55
lines changed

applier.go

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type Applier struct {
2828
commit *github.Commit
2929
tree string
3030
treeCache map[string]*github.Tree
31-
entries []*github.TreeEntry
31+
entries map[string]*github.TreeEntry
3232
uncommitted bool
3333
}
3434

@@ -55,30 +55,20 @@ func (a *Applier) Apply(ctx context.Context, f *gitdiff.File) (*github.TreeEntry
5555
// in particular, we need IsNew, IsDelete, maybe IsCopy and IsRename to
5656
// agree with the fragments and NewName/OldName
5757

58-
// Because of the tree cache, files must be part of a remote (i.e. created)
59-
// tree before they are modifiable. This could be fixed by refactoring the
60-
// apply logic but doesn't seem like an onerous restriction.
61-
for _, e := range a.entries {
62-
if e.GetPath() == f.NewName {
63-
return nil, errors.New("cannot apply with pending changes to file")
64-
}
65-
}
66-
58+
var entry *github.TreeEntry
6759
var err error
6860
switch {
6961
case f.IsNew:
70-
err = a.applyCreate(ctx, f)
62+
entry, err = a.applyCreate(ctx, f)
7163
case f.IsDelete:
72-
err = a.applyDelete(ctx, f)
64+
entry, err = a.applyDelete(ctx, f)
7365
default:
74-
err = a.applyModify(ctx, f)
66+
entry, err = a.applyModify(ctx, f)
7567
}
7668
if err != nil {
7769
return nil, err
7870
}
7971

80-
entry := a.entries[len(a.entries)-1]
81-
8272
if entry.Content != nil {
8373
blob, _, err := a.client.Git.CreateBlob(ctx, a.owner, a.repo, &github.Blob{
8474
Content: entry.Content,
@@ -94,103 +84,115 @@ func (a *Applier) Apply(ctx context.Context, f *gitdiff.File) (*github.TreeEntry
9484
return entry, nil
9585
}
9686

97-
func (a *Applier) applyCreate(ctx context.Context, f *gitdiff.File) error {
87+
func (a *Applier) applyCreate(ctx context.Context, f *gitdiff.File) (*github.TreeEntry, error) {
9888
_, exists, err := a.getEntry(ctx, f.NewName)
9989
if err != nil {
100-
return err
90+
return nil, err
10191
}
10292
if exists {
103-
return errors.New("existing entry for new file")
93+
return nil, errors.New("existing entry for new file")
10494
}
10595

10696
c, err := base64Apply(nil, f)
10797
if err != nil {
108-
return err
98+
return nil, err
10999
}
110100

111-
a.entries = append(a.entries, &github.TreeEntry{
112-
Path: github.String(f.NewName),
101+
path := f.NewName
102+
newEntry := &github.TreeEntry{
103+
Path: &path,
113104
Mode: github.String(getMode(f, nil)),
114105
Type: github.String("blob"),
115106
Content: &c,
116-
})
117-
return nil
107+
}
108+
a.entries[path] = newEntry
109+
110+
return newEntry, nil
118111
}
119112

120-
func (a *Applier) applyDelete(ctx context.Context, f *gitdiff.File) error {
113+
func (a *Applier) applyDelete(ctx context.Context, f *gitdiff.File) (*github.TreeEntry, error) {
121114
entry, exists, err := a.getEntry(ctx, f.OldName)
122115
if err != nil {
123-
return err
116+
return nil, err
124117
}
125118
if !exists {
126119
// because the rest of application is strict, return an error if the
127120
// file was already deleted, since it indicates a conflict of some kind
128-
return errors.New("missing entry for deleted file")
121+
return nil, errors.New("missing entry for deleted file")
129122
}
130123

131124
data, _, err := a.client.Git.GetBlobRaw(ctx, a.owner, a.repo, entry.GetSHA())
132125
if err != nil {
133-
return fmt.Errorf("get blob content failed: %w", err)
126+
return nil, fmt.Errorf("get blob content failed: %w", err)
134127
}
135128

136129
if err := gitdiff.Apply(ioutil.Discard, bytes.NewReader(data), f); err != nil {
137-
return err
130+
return nil, err
138131
}
139132

140-
a.entries = append(a.entries, &github.TreeEntry{
141-
Path: github.String(f.OldName),
133+
path := f.OldName
134+
newEntry := &github.TreeEntry{
135+
Path: &path,
142136
Mode: entry.Mode,
143-
})
144-
return nil
137+
}
138+
a.entries[path] = newEntry
139+
140+
return newEntry, nil
145141
}
146142

147-
func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) error {
143+
func (a *Applier) applyModify(ctx context.Context, f *gitdiff.File) (*github.TreeEntry, error) {
148144
entry, exists, err := a.getEntry(ctx, f.OldName)
149145
if err != nil {
150-
return err
146+
return nil, err
151147
}
152148
if !exists {
153-
return errors.New("no entry for modified file")
149+
return nil, errors.New("no entry for modified file")
154150
}
155151

152+
path := f.NewName
156153
newEntry := &github.TreeEntry{
157-
Path: github.String(f.NewName),
154+
Path: &path,
158155
Mode: github.String(getMode(f, entry)),
159156
Type: github.String("blob"),
160157
}
161158

162159
if len(f.TextFragments) > 0 || f.BinaryFragment != nil {
163160
data, _, err := a.client.Git.GetBlobRaw(ctx, a.owner, a.repo, entry.GetSHA())
164161
if err != nil {
165-
return fmt.Errorf("get blob content failed: %w", err)
162+
return nil, fmt.Errorf("get blob content failed: %w", err)
166163
}
167164

168165
c, err := base64Apply(data, f)
169166
if err != nil {
170-
return err
167+
return nil, err
171168
}
172169
newEntry.Content = &c
173170
}
174171

175172
// delete the old file if it was renamed
176173
if f.OldName != f.NewName {
177-
a.entries = append(a.entries, &github.TreeEntry{
178-
Path: github.String(f.OldName),
174+
path := f.OldName
175+
a.entries[path] = &github.TreeEntry{
176+
Path: &path,
179177
Mode: entry.Mode,
180-
})
178+
}
181179
}
182180

183181
if newEntry.Content == nil {
184182
newEntry.SHA = entry.SHA
185183
}
186-
a.entries = append(a.entries, newEntry)
184+
a.entries[path] = newEntry
187185

188-
return nil
186+
return newEntry, nil
189187
}
190188

191189
// Entries returns the list of pending tree entries.
192190
func (a *Applier) Entries() []*github.TreeEntry {
193-
return a.entries
191+
entries := make([]*github.TreeEntry, 0, len(a.entries))
192+
for _, e := range a.entries {
193+
entries = append(entries, e)
194+
}
195+
return entries
194196
}
195197

196198
// CreateTree creates a tree from the pending tree entries and clears the entry
@@ -201,16 +203,16 @@ func (a *Applier) CreateTree(ctx context.Context) (*github.Tree, error) {
201203
return nil, errors.New("no pending tree entries")
202204
}
203205

204-
tree, _, err := a.client.Git.CreateTree(ctx, a.owner, a.repo, a.tree, a.entries)
206+
tree, _, err := a.client.Git.CreateTree(ctx, a.owner, a.repo, a.tree, a.Entries())
205207
if err != nil {
206208
return nil, err
207209
}
208210

209211
// Clear the tree cache here because we only cache trees that lead to file
210212
// modifications, and any change creates a new (i.e. uncached) tree SHA
211-
a.treeCache = make(map[string]*github.Tree)
212213
a.tree = tree.GetSHA()
213-
a.entries = nil
214+
a.treeCache = make(map[string]*github.Tree)
215+
a.entries = make(map[string]*github.TreeEntry)
214216
a.uncommitted = true
215217
return tree, nil
216218
}
@@ -276,13 +278,22 @@ func (a *Applier) Reset(c *github.Commit) {
276278
a.commit = c
277279
a.tree = c.GetTree().GetSHA()
278280
a.treeCache = make(map[string]*github.Tree)
279-
a.entries = nil
281+
a.entries = make(map[string]*github.TreeEntry)
280282
a.uncommitted = false
281283
}
282284

283-
// getEntry returns the tree entry for path in the base tree. Returns nil and
284-
// false if no entry exists for path.
285+
// getEntry returns the tree entry for a path. If the path has a pending
286+
// change, return the entry representing that change, otherwise return an entry
287+
// from the base tree. Returns nil and false if no entry exists for path.
285288
func (a *Applier) getEntry(ctx context.Context, path string) (*github.TreeEntry, bool, error) {
289+
if entry, ok := a.entries[path]; ok {
290+
if entry.SHA == nil && entry.Content == nil {
291+
// The existing entry is a deletion, so pretend it doesn't exist
292+
return nil, false, nil
293+
}
294+
return entry, true, nil
295+
}
296+
286297
parts := strings.Split(path, "/")
287298
dir, name := parts[:len(parts)-1], parts[len(parts)-1]
288299

applier_test.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,22 @@ func assertPatchResult(t *testing.T, tctx *TestContext, name string, c *github.C
131131
return err
132132
}
133133

134-
content, err := os.ReadFile(path)
135-
if err != nil {
136-
return err
134+
var content []byte
135+
if info.Mode()&fs.ModeSymlink > 0 {
136+
target, err := os.Readlink(path)
137+
if err != nil {
138+
return err
139+
}
140+
content = []byte(target)
141+
} else {
142+
content, err = os.ReadFile(path)
143+
if err != nil {
144+
return err
145+
}
137146
}
138147

139148
expected[relpath] = treeFile{
140-
Mode: fmt.Sprintf("100%o", info.Mode()),
149+
Mode: getGitMode(info),
141150
Content: content,
142151
}
143152

@@ -200,6 +209,13 @@ func entriesToMap(entries []*github.TreeEntry) map[string]treeFile {
200209
return m
201210
}
202211

212+
func getGitMode(info fs.FileInfo) string {
213+
if info.Mode()&fs.ModeSymlink > 0 {
214+
return "120000"
215+
}
216+
return fmt.Sprintf("100%o", info.Mode())
217+
}
218+
203219
func prepareTestContext(t *testing.T) *TestContext {
204220
id := strconv.FormatInt(time.Now().UnixNano()/1000000, 10)
205221

@@ -257,7 +273,7 @@ func createBranch(t *testing.T, tctx *TestContext) {
257273
entry := github.TreeEntry{
258274
Path: &treePath,
259275
Type: github.String("blob"),
260-
Mode: github.String(fmt.Sprintf("100%o", info.Mode())),
276+
Mode: github.String(getGitMode(info)),
261277
}
262278

263279
if strings.HasSuffix(d.Name(), ".bin") {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
diff --git a/script.sh b/script.sh
2+
deleted file mode 100755
3+
index b630d5e..0000000
4+
--- a/script.sh
5+
+++ /dev/null
6+
@@ -1,2 +0,0 @@
7+
-#!/bin/bash
8+
-echo "This file is executable"
9+
diff --git a/script.sh b/script.sh
10+
new file mode 120000
11+
index 0000000..7b2f763
12+
--- /dev/null
13+
+++ b/script.sh
14+
@@ -0,0 +1 @@
15+
+scripts/script.sh
16+
\ No newline at end of file
17+
diff --git a/scripts/script.sh b/scripts/script.sh
18+
new file mode 100755
19+
index 0000000..b630d5e
20+
--- /dev/null
21+
+++ b/scripts/script.sh
22+
@@ -0,0 +1,2 @@
23+
+#!/bin/bash
24+
+echo "This file is executable"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
scripts/script.sh
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
echo "This file is executable"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
diff --git a/README.md b/README.md
2+
index 3eaf470..2348a18 100644
3+
--- a/README.md
4+
+++ b/README.md
5+
@@ -2,3 +2,5 @@
6+
7+
This directory contains arbitrary files used to construct a base branch against
8+
which tests apply patches.
9+
+
10+
+This is the first modification.
11+
diff --git a/README.md b/README.md
12+
index 2348a18..11c536d 100644
13+
--- a/README.md
14+
+++ b/README.md
15+
@@ -1,5 +1,7 @@
16+
# Test Data
17+
18+
+This is the second modification.
19+
+
20+
This directory contains arbitrary files used to construct a base branch against
21+
which tests apply patches.
22+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Test Data
2+
3+
This is the second modification.
4+
5+
This directory contains arbitrary files used to construct a base branch against
6+
which tests apply patches.
7+
8+
This is the first modification.

0 commit comments

Comments
 (0)