mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 13:21:00 +00:00
The whole check to avoid pushes when they were only rebases was broken and compared the wrong things. Unfortunately this worked for nearly all unit tests, except for one were I used the wrong assertion. This fixed the check by comparing the right things and inverting the assertion in the unit test to make sure things do not break again in the future. Bug was introduced in #114.
298 lines
7 KiB
Go
298 lines
7 KiB
Go
package git
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
|
|
"github.com/apricote/releaser-pleaser/internal/updater"
|
|
)
|
|
|
|
const (
|
|
remoteName = "origin"
|
|
newFilePermissions = 0o644
|
|
)
|
|
|
|
type Commit struct {
|
|
Hash string
|
|
Message string
|
|
|
|
PullRequest *PullRequest
|
|
}
|
|
|
|
type PullRequest struct {
|
|
ID int
|
|
Title string
|
|
Description string
|
|
}
|
|
|
|
type Tag struct {
|
|
Hash string
|
|
Name string
|
|
}
|
|
|
|
type Releases struct {
|
|
Latest *Tag
|
|
Stable *Tag
|
|
}
|
|
|
|
type Author struct {
|
|
Name string
|
|
Email string
|
|
}
|
|
|
|
func (a Author) signature(when time.Time) *object.Signature {
|
|
return &object.Signature{
|
|
Name: a.Name,
|
|
Email: a.Email,
|
|
When: when,
|
|
}
|
|
}
|
|
|
|
func (a Author) String() string {
|
|
return fmt.Sprintf("%s <%s>", a.Name, a.Email)
|
|
}
|
|
|
|
var (
|
|
committer = Author{Name: "releaser-pleaser", Email: ""}
|
|
)
|
|
|
|
func CloneRepo(ctx context.Context, logger *slog.Logger, cloneURL, branch string, auth transport.AuthMethod) (*Repository, error) {
|
|
dir, err := os.MkdirTemp("", "releaser-pleaser.*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
|
|
}
|
|
|
|
repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
|
|
URL: cloneURL,
|
|
RemoteName: remoteName,
|
|
ReferenceName: plumbing.NewBranchReferenceName(branch),
|
|
SingleBranch: false,
|
|
Auth: auth,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to clone repository: %w", err)
|
|
}
|
|
|
|
return &Repository{r: repo, logger: logger, auth: auth}, nil
|
|
}
|
|
|
|
type Repository struct {
|
|
r *git.Repository
|
|
logger *slog.Logger
|
|
auth transport.AuthMethod
|
|
}
|
|
|
|
func (r *Repository) DeleteBranch(ctx context.Context, branch string) error {
|
|
if b, _ := r.r.Branch(branch); b != nil {
|
|
r.logger.DebugContext(ctx, "deleting local branch", "branch.name", branch)
|
|
if err := r.r.DeleteBranch(branch); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) Checkout(_ context.Context, branch string) error {
|
|
worktree, err := r.r.Worktree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = worktree.Checkout(&git.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName(branch),
|
|
Create: true,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to check out branch: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) UpdateFile(_ context.Context, path string, create bool, updaters []updater.Updater) error {
|
|
worktree, err := r.r.Worktree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fileFlags := os.O_RDWR
|
|
if create {
|
|
fileFlags |= os.O_CREATE
|
|
}
|
|
|
|
file, err := worktree.Filesystem.OpenFile(path, fileFlags, newFilePermissions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close() //nolint:errcheck
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updatedContent := string(content)
|
|
|
|
for _, update := range updaters {
|
|
updatedContent, err = update(updatedContent)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run updater on file %s", path)
|
|
}
|
|
}
|
|
|
|
err = file.Truncate(0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to replace file content: %w", err)
|
|
}
|
|
_, err = file.Seek(0, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to replace file content: %w", err)
|
|
}
|
|
_, err = file.Write([]byte(updatedContent))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to replace file content: %w", err)
|
|
}
|
|
|
|
_, err = worktree.Add(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add updated file to git worktree: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) Commit(_ context.Context, message string, author Author) (Commit, error) {
|
|
worktree, err := r.r.Worktree()
|
|
if err != nil {
|
|
return Commit{}, err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
releaseCommitHash, err := worktree.Commit(message, &git.CommitOptions{
|
|
Author: author.signature(now),
|
|
Committer: committer.signature(now),
|
|
})
|
|
if err != nil {
|
|
return Commit{}, fmt.Errorf("failed to commit changes: %w", err)
|
|
}
|
|
|
|
return Commit{
|
|
Hash: releaseCommitHash.String(),
|
|
Message: message,
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
return true, nil
|
|
}
|
|
|
|
return false, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
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 nil, err
|
|
}
|
|
|
|
commit, err := r.r.CommitObject(ref.Hash())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 {
|
|
pushRefSpec := config.RefSpec(fmt.Sprintf(
|
|
"+%s:%s",
|
|
plumbing.NewBranchReferenceName(branch),
|
|
// This needs to be the local branch name, not the remotes/origin ref
|
|
// See https://stackoverflow.com/a/75727620
|
|
plumbing.NewBranchReferenceName(branch),
|
|
))
|
|
|
|
r.logger.DebugContext(ctx, "pushing branch", "branch.name", branch, "refspec", pushRefSpec.String())
|
|
return r.r.PushContext(ctx, &git.PushOptions{
|
|
RemoteName: remoteName,
|
|
RefSpecs: []config.RefSpec{pushRefSpec},
|
|
Force: true,
|
|
Auth: r.auth,
|
|
})
|
|
}
|