Skip to content

Commit ddeabca

Browse files
committed
add helper function to get changed files
1 parent f45a01e commit ddeabca

File tree

3 files changed

+284
-1
lines changed

3 files changed

+284
-1
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/chainguard-dev/clog v1.5.1-0.20240811185937-4c523ae4593f
1616
github.com/cloudevents/sdk-go/v2 v2.15.2
1717
github.com/coreos/go-oidc/v3 v3.11.0
18+
github.com/go-git/go-billy/v5 v5.5.0
1819
github.com/go-git/go-git/v5 v5.12.0
1920
github.com/google/go-cmp v0.6.0
2021
github.com/google/go-github/v61 v61.0.0
@@ -57,7 +58,6 @@ require (
5758
github.com/emirpasic/gods v1.18.1 // indirect
5859
github.com/felixge/httpsnoop v1.0.4 // indirect
5960
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
60-
github.com/go-git/go-billy/v5 v5.5.0 // indirect
6161
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
6262
github.com/go-logr/logr v1.4.2 // indirect
6363
github.com/go-logr/stdr v1.2.2 // indirect

modules/github-bots/sdk/github.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/go-git/go-git/v5/config"
1919
"github.com/go-git/go-git/v5/plumbing"
2020
gitHttp "github.com/go-git/go-git/v5/plumbing/transport/http"
21+
"github.com/go-git/go-git/v5/utils/merkletrie"
2122
"github.com/snabb/httpreaderat"
2223

2324
"chainguard.dev/sdk/octosts"
@@ -433,6 +434,83 @@ func (c GitHubClient) ListArtifactsFunc(ctx context.Context, wr *github.Workflow
433434
return nil
434435
}
435436

437+
// GetChangedFiles uses the git package to get a map of the files changed between two branches.
438+
func (c GitHubClient) GetChangedFiles(ctx context.Context, repo *git.Repository, from, to string) (map[string]struct{}, error) {
439+
if repo == nil {
440+
return nil, fmt.Errorf("repository is nil")
441+
}
442+
443+
parseRef := func(ref string) (*plumbing.Reference, error) {
444+
if plumbing.IsHash(ref) {
445+
return plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(ref)), nil
446+
}
447+
return repo.Reference(plumbing.ReferenceName(ref), true)
448+
}
449+
450+
fromRef, err := parseRef(from)
451+
if err != nil {
452+
return nil, fmt.Errorf("failed to get reference for %s: %w", from, err)
453+
}
454+
455+
toRef, err := parseRef(to)
456+
if err != nil {
457+
return nil, fmt.Errorf("failed to get reference for %s: %w", to, err)
458+
}
459+
460+
if fromRef == nil || toRef == nil {
461+
return nil, fmt.Errorf("one or both references are nil")
462+
}
463+
464+
fromCommit, err := repo.CommitObject(fromRef.Hash())
465+
if err != nil {
466+
return nil, fmt.Errorf("failed to get commit for %s: %w", fromRef.Hash().String(), err)
467+
}
468+
469+
toCommit, err := repo.CommitObject(toRef.Hash())
470+
if err != nil {
471+
return nil, fmt.Errorf("failed to get commit for %s: %w", toRef.Hash().String(), err)
472+
}
473+
474+
if fromCommit == nil || toCommit == nil {
475+
return nil, fmt.Errorf("one or both commits are nil")
476+
}
477+
478+
changed := make(map[string]struct{})
479+
480+
fromTree, err := fromCommit.Tree()
481+
if err != nil {
482+
return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef.Hash().String(), err)
483+
}
484+
485+
toTree, err := toCommit.Tree()
486+
if err != nil {
487+
return nil, fmt.Errorf("failed to get tree for %s: %w", toRef.String(), err)
488+
}
489+
490+
changes, err := fromTree.Diff(toTree)
491+
if err != nil {
492+
return nil, fmt.Errorf("failed to diff trees: %w", err)
493+
}
494+
495+
for _, change := range changes {
496+
action, err := change.Action()
497+
if err != nil {
498+
return nil, fmt.Errorf("failed to get change action: %w", err)
499+
}
500+
501+
switch action {
502+
case merkletrie.Insert:
503+
changed[change.To.Name] = struct{}{}
504+
case merkletrie.Delete:
505+
changed[change.From.Name] = struct{}{}
506+
case merkletrie.Modify:
507+
changed[change.From.Name] = struct{}{}
508+
}
509+
}
510+
511+
return changed, nil
512+
}
513+
436514
func validateResponse(ctx context.Context, err error, resp *github.Response, action string) error {
437515
// resp may be nil if err is nonempty. However, err may contain a rate limit
438516
// error so we have to inspect for rate limiting if resp is non-nil
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package sdk_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/chainguard-dev/terraform-infra-common/modules/github-bots/sdk"
9+
"github.com/go-git/go-billy/v5/osfs"
10+
"github.com/go-git/go-git/v5"
11+
"github.com/go-git/go-git/v5/plumbing"
12+
"github.com/go-git/go-git/v5/plumbing/object"
13+
"github.com/go-git/go-git/v5/storage/memory"
14+
"github.com/google/go-cmp/cmp"
15+
)
16+
17+
func TestGithubClient_GetChangedFiles(t *testing.T) {
18+
ctx := context.Background()
19+
client := sdk.NewGitHubClient(ctx, "org", "repo", "bot")
20+
21+
tests := []struct {
22+
name string
23+
patches []gpatch
24+
expected map[string]struct{}
25+
wantErr bool
26+
}{
27+
{
28+
name: "Added file",
29+
patches: []gpatch{
30+
{action: "add", path: "new.txt"},
31+
},
32+
expected: map[string]struct{}{
33+
"new.txt": {},
34+
},
35+
},
36+
{
37+
name: "Modified file",
38+
patches: []gpatch{
39+
{action: "add", path: "existing.txt"},
40+
{action: "modify", path: "existing.txt"},
41+
},
42+
expected: map[string]struct{}{
43+
"existing.txt": {},
44+
},
45+
},
46+
{
47+
name: "Renamed file",
48+
patches: []gpatch{
49+
{action: "add", path: "old.txt"},
50+
{action: "rename", path: "old.txt", newPath: "new.txt"},
51+
},
52+
expected: map[string]struct{}{
53+
"new.txt": {},
54+
},
55+
},
56+
{
57+
name: "Deleted file",
58+
patches: []gpatch{
59+
{action: "add", path: "to_delete.txt"},
60+
{action: "delete", path: "initial.txt"},
61+
},
62+
expected: map[string]struct{}{
63+
"to_delete.txt": {},
64+
"initial.txt": {},
65+
},
66+
},
67+
{
68+
name: "Multiple changes",
69+
patches: []gpatch{
70+
{action: "add", path: "new.txt"},
71+
{action: "add", path: "to_modify.txt"},
72+
{action: "modify", path: "to_modify.txt"},
73+
{action: "add", path: "to_rename.txt"},
74+
{action: "rename", path: "to_rename.txt", newPath: "renamed.txt"},
75+
{action: "add", path: "to_delete.txt"},
76+
{action: "delete", path: "to_delete.txt"},
77+
},
78+
expected: map[string]struct{}{
79+
"new.txt": {},
80+
"to_modify.txt": {},
81+
"renamed.txt": {},
82+
},
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
repo := setupTestRepo(t, tt.patches)
89+
90+
got, err := client.GetChangedFiles(ctx, repo, "feature", "main")
91+
if (err != nil) != tt.wantErr {
92+
t.Errorf("GetChangedFiles() error = %v, wantErr %v", err, tt.wantErr)
93+
return
94+
}
95+
if err != nil {
96+
t.Logf("Error details: %+v", err)
97+
}
98+
if diff := cmp.Diff(got, tt.expected); diff != "" {
99+
t.Errorf("GetChangedFiles() mismatch (-want +got):\n%s", diff)
100+
}
101+
})
102+
}
103+
}
104+
105+
type gpatch struct {
106+
action string
107+
path string
108+
newPath string
109+
}
110+
111+
func setupTestRepo(t *testing.T, patches []gpatch) *git.Repository {
112+
t.Helper()
113+
114+
dir := t.TempDir()
115+
fs := osfs.New(dir)
116+
repo, err := git.Init(memory.NewStorage(), fs)
117+
if err != nil {
118+
t.Fatalf("failed to init git repo: %v", err)
119+
}
120+
121+
w, err := repo.Worktree()
122+
if err != nil {
123+
t.Fatalf("failed to get worktree: %v", err)
124+
}
125+
126+
// Create and commit an initial file on main
127+
if err := writeFile(w, "initial.txt", "initial content"); err != nil {
128+
t.Fatalf("failed to create initial file: %v", err)
129+
}
130+
if _, err := w.Add("initial.txt"); err != nil {
131+
t.Fatalf("failed to add initial file: %v", err)
132+
}
133+
if _, err := w.Commit("Initial commit", &git.CommitOptions{
134+
Author: &object.Signature{Name: "Test Author", Email: "[email protected]"},
135+
}); err != nil {
136+
t.Fatalf("failed to commit initial file: %v", err)
137+
}
138+
139+
// Create main branch
140+
mainRef := plumbing.NewBranchReferenceName("main")
141+
headRef, err := repo.Head()
142+
if err != nil {
143+
t.Fatalf("failed to get HEAD reference: %v", err)
144+
}
145+
if err := repo.Storer.SetReference(plumbing.NewHashReference(mainRef, headRef.Hash())); err != nil {
146+
t.Fatalf("failed to set main branch reference: %v", err)
147+
}
148+
149+
// Create and checkout feature branch
150+
if err := w.Checkout(&git.CheckoutOptions{
151+
Create: true,
152+
Branch: plumbing.NewBranchReferenceName("feature"),
153+
}); err != nil {
154+
t.Fatalf("failed to checkout feature branch: %v", err)
155+
}
156+
157+
// Apply patches
158+
for _, p := range patches {
159+
if err := applyPatch(w, p); err != nil {
160+
t.Fatalf("failed to apply patch: %v", err)
161+
}
162+
}
163+
164+
// Commit changes
165+
if _, err := w.Add("."); err != nil {
166+
t.Fatalf("failed to add changes: %v", err)
167+
}
168+
if _, err := w.Commit("Test changes", &git.CommitOptions{
169+
Author: &object.Signature{Name: "Test Author", Email: "[email protected]"},
170+
}); err != nil {
171+
t.Fatalf("failed to commit changes: %v", err)
172+
}
173+
174+
return repo
175+
}
176+
177+
func applyPatch(w *git.Worktree, p gpatch) error {
178+
switch p.action {
179+
case "add":
180+
return writeFile(w, p.path, "content")
181+
case "modify":
182+
return writeFile(w, p.path, "modified content")
183+
case "rename":
184+
if err := w.Filesystem.Rename(p.path, p.newPath); err != nil {
185+
return fmt.Errorf("failed to rename file: %w", err)
186+
}
187+
case "delete":
188+
if err := w.Filesystem.Remove(p.path); err != nil {
189+
return fmt.Errorf("failed to delete file: %w", err)
190+
}
191+
}
192+
return nil
193+
}
194+
195+
func writeFile(w *git.Worktree, path, content string) error {
196+
f, err := w.Filesystem.Create(path)
197+
if err != nil {
198+
return fmt.Errorf("failed to create file %s: %w", path, err)
199+
}
200+
defer f.Close()
201+
if _, err := f.Write([]byte(content)); err != nil {
202+
return fmt.Errorf("failed to write to file %s: %w", path, err)
203+
}
204+
return nil
205+
}

0 commit comments

Comments
 (0)