This commit is contained in:
Julian Tölle 2024-12-21 23:15:16 +01:00 committed by GitHub
commit c85e3d96de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 353 additions and 11 deletions

2
go.mod
View file

@ -6,6 +6,7 @@ toolchain go1.23.4
require (
github.com/blang/semver/v4 v4.0.0
github.com/go-git/go-billy/v5 v5.5.0
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-github/v66 v66.0.0
github.com/leodido/go-conventionalcommits v0.12.0
@ -25,7 +26,6 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect

View file

@ -170,8 +170,19 @@ func (r *Repository) Commit(_ context.Context, message string) (Commit, error) {
}, nil
}
func (r *Repository) HasChangesWithRemote(ctx context.Context, branch string) (bool, error) {
remoteRef, err := r.r.Reference(plumbing.NewRemoteReferenceName(remoteName, branch), false)
// HasChangesWithRemote checks if the following two diffs are equal:
//
// - **Local**: remote/main..branch
// - **Remote**: (git merge-base remote/main remote/branch)..remote/branch
//
// This is done to avoid pushing when the only change would be a rebase of remote/branch onto the current remote/main.
func (r *Repository) HasChangesWithRemote(ctx context.Context, mainBranch, prBranch string) (bool, error) {
commitOnRemoteMain, err := r.commitFromRef(plumbing.NewRemoteReferenceName(remoteName, mainBranch))
if err != nil {
return false, err
}
commitOnRemotePRBranch, err := r.commitFromRef(plumbing.NewRemoteReferenceName(remoteName, prBranch))
if err != nil {
if err.Error() == "reference not found" {
// No remote branch means that there are changes
@ -181,29 +192,60 @@ func (r *Repository) HasChangesWithRemote(ctx context.Context, branch string) (b
return false, err
}
remoteCommit, err := r.r.CommitObject(remoteRef.Hash())
currentRemotePRMergeBase, err := r.mergeBase(commitOnRemoteMain, commitOnRemotePRBranch)
if err != nil {
return false, err
}
if currentRemotePRMergeBase == nil {
// If there is no merge base something weird has happened with the
// remote main branch, and we should definitely push updates.
return false, nil
}
remoteDiff, err := currentRemotePRMergeBase.PatchContext(ctx, commitOnRemotePRBranch)
if err != nil {
return false, err
}
localRef, err := r.r.Reference(plumbing.NewBranchReferenceName(branch), false)
commitOnLocalPRBranch, err := r.commitFromRef(plumbing.NewBranchReferenceName(prBranch))
if err != nil {
return false, err
}
localCommit, err := r.r.CommitObject(localRef.Hash())
localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch)
if err != nil {
return false, err
}
diff, err := localCommit.PatchContext(ctx, remoteCommit)
return remoteDiff.String() == localDiff.String(), nil
}
func (r *Repository) commitFromRef(refName plumbing.ReferenceName) (*object.Commit, error) {
ref, err := r.r.Reference(refName, false)
if err != nil {
return false, err
return nil, err
}
hasChanges := len(diff.FilePatches()) > 0
commit, err := r.r.CommitObject(ref.Hash())
if err != nil {
return nil, err
}
return hasChanges, nil
return commit, nil
}
func (r *Repository) mergeBase(a, b *object.Commit) (*object.Commit, error) {
mergeBases, err := a.MergeBase(b)
if err != nil {
return nil, err
}
if len(mergeBases) == 0 {
return nil, nil
}
// :shrug: We dont really care which commit we pick, at worst we do an unnecessary push.
return mergeBases[0], nil
}
func (r *Repository) ForcePush(ctx context.Context, branch string) error {

131
internal/git/git_test.go Normal file
View file

@ -0,0 +1,131 @@
package git
import (
"context"
"testing"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
)
const testMainBranch = "main"
const testPRBranch = "releaser-pleaser"
func TestRepository_HasChangesWithRemote(t *testing.T) {
tests := []struct {
name string
repo TestRepo
want bool
wantErr assert.ErrorAssertionFunc
}{
{
name: "no remote pr branch",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
OnBranch(plumbing.NewBranchReferenceName(testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewBranchReferenceName(testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
),
want: true,
wantErr: assert.NoError,
},
{
name: "remote pr branch matches local",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
OnBranch(plumbing.NewBranchReferenceName(testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewBranchReferenceName(testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
),
want: false,
wantErr: assert.NoError,
},
{
name: "remote pr only needs rebase",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
OnBranch(plumbing.NewBranchReferenceName(testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
WithCommit(
"feat: new feature on remote",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
WithFile("feature", "yes"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewBranchReferenceName(testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
),
want: false,
wantErr: assert.NoError,
},
{
name: "needs update",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
OnBranch(plumbing.NewBranchReferenceName(testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewRemoteReferenceName(remoteName, testPRBranch)),
WithFile("VERSION", "v1.1.0"),
),
WithCommit(
"chore: release v1.2.0",
OnBranch(plumbing.NewRemoteReferenceName(remoteName, testMainBranch)),
AsNewBranch(plumbing.NewBranchReferenceName(testPRBranch)),
WithFile("VERSION", "v1.2.0"),
),
),
want: false,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := tt.repo(t)
got, err := repo.HasChangesWithRemote(context.Background(), testMainBranch, testPRBranch)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}

169
internal/git/util_test.go Normal file
View file

@ -0,0 +1,169 @@
package git
import (
"io"
"log/slog"
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/stretchr/testify/require"
)
var (
author = &object.Signature{
Name: "releaser-pleaser",
When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC),
}
)
type CommitOption func(*commitOptions)
type commitOptions struct {
cleanFiles bool
files []commitFile
tags []string
newRef plumbing.ReferenceName
parentRef plumbing.ReferenceName
}
type commitFile struct {
path string
content string
}
type TestCommit func(*testing.T, *Repository) error
type TestRepo func(*testing.T) *Repository
func WithCommit(message string, options ...CommitOption) TestCommit {
return func(t *testing.T, repo *Repository) error {
t.Helper()
require.NotEmpty(t, message, "commit message is required")
opts := &commitOptions{}
for _, opt := range options {
opt(opts)
}
wt, err := repo.r.Worktree()
require.NoError(t, err)
if opts.parentRef != "" {
checkoutOptions := &git.CheckoutOptions{}
if opts.newRef != "" {
parentRef, err := repo.r.Reference(opts.parentRef, false)
require.NoError(t, err)
checkoutOptions.Create = true
checkoutOptions.Hash = parentRef.Hash()
checkoutOptions.Branch = opts.newRef
} else {
checkoutOptions.Branch = opts.parentRef
}
err = wt.Checkout(checkoutOptions)
require.NoError(t, err)
}
// Yeet all files
if opts.cleanFiles {
files, err := wt.Filesystem.ReadDir(".")
require.NoError(t, err, "failed to get current files")
for _, fileInfo := range files {
err = wt.Filesystem.Remove(fileInfo.Name())
require.NoError(t, err, "failed to remove file %q", fileInfo.Name())
}
}
// Create new files
for _, fileInfo := range opts.files {
file, err := wt.Filesystem.Create(fileInfo.path)
require.NoError(t, err, "failed to create file %q", fileInfo.path)
_, err = file.Write([]byte(fileInfo.content))
_ = file.Close()
require.NoError(t, err, "failed to write content to file %q", fileInfo.path)
}
// Commit
commitHash, err := wt.Commit(message, &git.CommitOptions{
All: true,
AllowEmptyCommits: true,
Author: author,
Committer: author,
})
require.NoError(t, err, "failed to commit")
// Create tags
for _, tagName := range opts.tags {
_, err = repo.r.CreateTag(tagName, commitHash, nil)
require.NoError(t, err, "failed to create tag %q", tagName)
}
return nil
}
}
func WithFile(path, content string) CommitOption {
return func(opts *commitOptions) {
opts.files = append(opts.files, commitFile{path: path, content: content})
}
}
func WithCleanFiles() CommitOption {
return func(opts *commitOptions) {
opts.cleanFiles = true
}
}
func AsNewBranch(ref plumbing.ReferenceName) CommitOption {
return func(opts *commitOptions) {
opts.newRef = ref
}
}
func OnBranch(ref plumbing.ReferenceName) CommitOption {
return func(opts *commitOptions) {
opts.parentRef = ref
}
}
func WithTag(name string) CommitOption {
return func(opts *commitOptions) {
opts.tags = append(opts.tags, name)
}
}
func WithTestRepo(commits ...TestCommit) TestRepo {
return func(t *testing.T) *Repository {
t.Helper()
repo := &Repository{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
var err error
repo.r, err = git.InitWithOptions(memory.NewStorage(), memfs.New(), git.InitOptions{
DefaultBranch: plumbing.Main,
})
require.NoError(t, err, "failed to create in-memory repository")
// Make initial commit
err = WithCommit("chore: init")(t, repo)
require.NoError(t, err, "failed to create init commit")
for i, commit := range commits {
err = commit(t, repo)
require.NoError(t, err, "failed to create commit %d", i)
}
return repo
}
}

View file

@ -274,7 +274,7 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message)
// Check if anything changed in comparison to the remote branch (if exists)
newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch)
newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rp.targetBranch, rpBranch)
if err != nil {
return err
}