mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 13:21:00 +00:00
This change reverses the responsibility for which files the updaters are run on. Now each updater can specify the list of files and wether the files should be created when they do not exist yet. This simplifies the handling of each update in releaserpleaser.go, as we can just iterate over all updaters and call it for each file of that updater. Also update the flags to allow users to easily define which updaters should run.
292 lines
6.9 KiB
Go
292 lines
6.9 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"
|
|
)
|
|
|
|
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, updateHook func(string) (string, error)) 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, err := updateHook(string(content))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run update hook 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,
|
|
})
|
|
}
|