From d486851fd7eee2ffee7c956e21949552b31fa5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Wed, 20 Nov 2024 22:03:57 +0100 Subject: [PATCH] feat: avoid pushing release branch only for rebasing --- internal/git/git.go | 68 ++++++++++++++++++--- internal/git/git_test.go | 126 +++++++++++++++++++++++++++++++++++++++ releaserpleaser.go | 2 +- 3 files changed, 186 insertions(+), 10 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index 95f05e0..136a828 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -193,8 +193,27 @@ func (r *Repository) Commit(_ context.Context, message string, author Author) (C }, 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) { + return r.hasChangesWithRemote(ctx, + plumbing.NewRemoteReferenceName(remoteName, mainBranch), + plumbing.NewBranchReferenceName(prBranch), + plumbing.NewRemoteReferenceName(remoteName, prBranch), + ) +} + +func (r *Repository) hasChangesWithRemote(ctx context.Context, mainBranchRef, localPRBranchRef, remotePRBranchRef plumbing.ReferenceName) (bool, error) { + commitOnRemoteMain, err := r.commitFromRef(mainBranchRef) + if err != nil { + return false, err + } + + commitOnRemotePRBranch, err := r.commitFromRef(remotePRBranchRef) if err != nil { if err.Error() == "reference not found" { // No remote branch means that there are changes @@ -204,29 +223,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 := commitOnRemotePRBranch.PatchContext(ctx, currentRemotePRMergeBase) if err != nil { return false, err } - localRef, err := r.r.Reference(plumbing.NewBranchReferenceName(branch), false) + commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef) 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 { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 399c2ba..3d05538 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,12 +1,15 @@ package git import ( + "context" "reflect" "strconv" "testing" "time" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" ) func TestAuthor_signature(t *testing.T) { @@ -44,3 +47,126 @@ func TestAuthor_String(t *testing.T) { }) } } + +const testMainBranch = "main" +const testPRBranch = "releaser-pleaser" + +func TestRepository_HasChangesWithRemote(t *testing.T) { + // go-git/v5 has a bug where it tries to delete the repo root dir (".") multiple times if there is no file left in it. + // this happens while switching branches in worktree.go rmFileAndDirsIfEmpty. + // TODO: Fix bug upstream + // For now I just make sure that there is always at least one file left in the dir by adding an empty "README.md" in the test util. + + mainBranchRef := plumbing.NewBranchReferenceName(testMainBranch) + localPRBranchRef := plumbing.NewBranchReferenceName(testPRBranch) + remotePRBranchRef := plumbing.NewBranchReferenceName("remote/" + testPRBranch) + + tests := []struct { + name string + repo TestRepo + want bool + wantErr assert.ErrorAssertionFunc + }{ + { + name: "no remote pr branch", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: true, + wantErr: assert.NoError, + }, + { + name: "remote pr branch matches local", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: false, + wantErr: assert.NoError, + }, + { + name: "remote pr only needs rebase", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + WithCommit( + "feat: new feature on remote", + OnBranch(mainBranchRef), + WithFile("feature", "yes"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: false, + wantErr: assert.NoError, + }, + { + name: "needs update", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + WithCommit( + "chore: release v1.2.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + 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(), mainBranchRef, localPRBranchRef, remotePRBranchRef) + if !tt.wantErr(t, err) { + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/releaserpleaser.go b/releaserpleaser.go index 13e2381..3c316ae 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -269,7 +269,7 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message, "commit.author", releaseCommitAuthor) // 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 }