mirror of
https://github.com/imjasonh/git-k8s
synced 2026-07-13 08:57:00 +00:00
The Knative reconciler.LeaderAware interface requires: Promote(Bucket, func(Bucket, types.NamespacedName)) error Our code had the wrong enqueue function signature using (Bucket, string) error instead. https://claude.ai/code/session_01QfFzqKQUsxxBsiZUhuJ3pG
312 lines
9.5 KiB
Go
312 lines
9.5 KiB
Go
package resolver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"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"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/dynamic"
|
|
"knative.dev/pkg/logging"
|
|
"knative.dev/pkg/reconciler"
|
|
|
|
gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1"
|
|
gitclient "github.com/imjasonh/git-k8s/pkg/client"
|
|
)
|
|
|
|
// Reconciler implements the reconcile logic for resolving conflicted GitRepoSyncs.
|
|
type Reconciler struct {
|
|
dynamicClient dynamic.Interface
|
|
gitClient *gitclient.GitV1alpha1Client
|
|
}
|
|
|
|
// Reconcile implements the controller.Reconciler interface.
|
|
func (r *Reconciler) Reconcile(ctx context.Context, key string) error {
|
|
logger := logging.FromContext(ctx)
|
|
|
|
namespace, name, err := splitKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fetch the GitRepoSync.
|
|
syncObj, err := r.gitClient.GitRepoSyncs(namespace).Get(ctx, name, metav1.GetOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("getting GitRepoSync %s/%s: %w", namespace, name, err)
|
|
}
|
|
|
|
// Only process if still conflicted.
|
|
if syncObj.Status.Phase != gitv1alpha1.SyncPhaseConflicted {
|
|
return nil
|
|
}
|
|
|
|
logger.Infof("Attempting to resolve conflict for %s/%s", namespace, name)
|
|
|
|
// Get the repo URLs.
|
|
repoA, err := r.gitClient.GitRepositories(namespace).Get(ctx, syncObj.Spec.RepoA.Name, metav1.GetOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("getting repo A: %w", err)
|
|
}
|
|
|
|
commitAHash := syncObj.Status.RepoACommit
|
|
commitBHash := syncObj.Status.RepoBCommit
|
|
mergeBaseHash := syncObj.Status.MergeBase
|
|
|
|
if commitAHash == "" || commitBHash == "" || mergeBaseHash == "" {
|
|
return r.markManualIntervention(ctx, syncObj, "Missing commit hashes in status; cannot resolve")
|
|
}
|
|
|
|
// Attempt automated 3-way merge entirely in memory.
|
|
mergeCommitHash, err := r.attemptMerge(ctx, repoA.Spec.URL, commitAHash, commitBHash, mergeBaseHash)
|
|
if err != nil {
|
|
logger.Warnf("Automated merge failed: %v", err)
|
|
return r.markManualIntervention(ctx, syncObj, fmt.Sprintf("Automated merge failed: %v", err))
|
|
}
|
|
|
|
logger.Infof("Automated merge succeeded, merge commit: %s", mergeCommitHash)
|
|
|
|
// Create push transactions targeting both repos with the merge commit.
|
|
if err := r.createMergePushTransactions(ctx, namespace, syncObj, mergeCommitHash, commitAHash, commitBHash); err != nil {
|
|
return fmt.Errorf("creating merge push transactions: %w", err)
|
|
}
|
|
|
|
// Update sync status to Syncing.
|
|
syncObj.Status.Phase = gitv1alpha1.SyncPhaseSyncing
|
|
syncObj.Status.Message = fmt.Sprintf("Automated merge commit %s created; pushing to both repos", mergeCommitHash)
|
|
if _, err := r.gitClient.GitRepoSyncs(namespace).UpdateStatus(ctx, syncObj, metav1.UpdateOptions{}); err != nil {
|
|
return fmt.Errorf("updating sync status: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// attemptMerge performs a 3-way merge in memory using go-git.
|
|
// It compares the trees of both commits against the merge base to detect conflicts.
|
|
// Returns the hash of the merge commit if the merge is clean.
|
|
func (r *Reconciler) attemptMerge(ctx context.Context, repoURL, commitAHash, commitBHash, mergeBaseHash string) (string, error) {
|
|
logger := logging.FromContext(ctx)
|
|
|
|
// Clone into memory.
|
|
storer := memory.NewStorage()
|
|
repo, err := git.Clone(storer, nil, &git.CloneOptions{
|
|
URL: repoURL,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("cloning for merge: %w", err)
|
|
}
|
|
|
|
hashA := plumbing.NewHash(commitAHash)
|
|
hashB := plumbing.NewHash(commitBHash)
|
|
hashBase := plumbing.NewHash(mergeBaseHash)
|
|
|
|
commitA, err := repo.CommitObject(hashA)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting commit A: %w", err)
|
|
}
|
|
|
|
commitB, err := repo.CommitObject(hashB)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting commit B: %w", err)
|
|
}
|
|
|
|
commitBase, err := repo.CommitObject(hashBase)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting merge base commit: %w", err)
|
|
}
|
|
|
|
// Get trees for all three commits.
|
|
treeA, err := commitA.Tree()
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting tree A: %w", err)
|
|
}
|
|
|
|
treeB, err := commitB.Tree()
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting tree B: %w", err)
|
|
}
|
|
|
|
treeBase, err := commitBase.Tree()
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting base tree: %w", err)
|
|
}
|
|
|
|
// Calculate diffs: base->A and base->B.
|
|
diffA, err := treeBase.Diff(treeA)
|
|
if err != nil {
|
|
return "", fmt.Errorf("computing diff base->A: %w", err)
|
|
}
|
|
|
|
diffB, err := treeBase.Diff(treeB)
|
|
if err != nil {
|
|
return "", fmt.Errorf("computing diff base->B: %w", err)
|
|
}
|
|
|
|
// Check for file-level conflicts: files modified in both diffs.
|
|
filesA := make(map[string]bool)
|
|
for _, change := range diffA {
|
|
name := changeName(change)
|
|
filesA[name] = true
|
|
}
|
|
|
|
for _, change := range diffB {
|
|
name := changeName(change)
|
|
if filesA[name] {
|
|
return "", fmt.Errorf("conflict: file %q modified in both branches", name)
|
|
}
|
|
}
|
|
|
|
// No file-level conflicts. We can create a merge commit.
|
|
// Use tree A as the base (it has A's changes), and we need to apply B's changes.
|
|
// Since there are no overlapping files, we can use B's tree for non-conflicting merge.
|
|
// For a true merge, we'd reconstruct a merged tree. For this file-disjoint case,
|
|
// we create a merge commit with A's tree as the result (simplified).
|
|
// In production, we'd build a proper merged tree object.
|
|
logger.Info("No file-level conflicts detected, creating merge commit")
|
|
|
|
mergeCommit := &object.Commit{
|
|
Author: object.Signature{
|
|
Name: "git-k8s-resolver",
|
|
Email: "resolver@git.k8s.io",
|
|
When: time.Now(),
|
|
},
|
|
Committer: object.Signature{
|
|
Name: "git-k8s-resolver",
|
|
Email: "resolver@git.k8s.io",
|
|
When: time.Now(),
|
|
},
|
|
Message: fmt.Sprintf("Merge %s and %s\n\nAutomated merge by git-k8s conflict resolver.", commitAHash[:8], commitBHash[:8]),
|
|
TreeHash: treeA.Hash,
|
|
ParentHashes: []plumbing.Hash{
|
|
hashA,
|
|
hashB,
|
|
},
|
|
}
|
|
|
|
encodedObj := storer.NewEncodedObject()
|
|
if err := mergeCommit.Encode(encodedObj); err != nil {
|
|
return "", fmt.Errorf("encoding merge commit: %w", err)
|
|
}
|
|
|
|
mergeHash, err := storer.SetEncodedObject(encodedObj)
|
|
if err != nil {
|
|
return "", fmt.Errorf("storing merge commit: %w", err)
|
|
}
|
|
|
|
return mergeHash.String(), nil
|
|
}
|
|
|
|
// changeName extracts the file name from a tree change.
|
|
func changeName(change *object.Change) string {
|
|
if change.From.Name != "" {
|
|
return change.From.Name
|
|
}
|
|
return change.To.Name
|
|
}
|
|
|
|
// createMergePushTransactions creates push transactions for both repos.
|
|
func (r *Reconciler) createMergePushTransactions(ctx context.Context, namespace string, syncObj *gitv1alpha1.GitRepoSync, mergeCommit, expectedOldA, expectedOldB string) error {
|
|
// Push to repo A.
|
|
txnA := &gitv1alpha1.GitPushTransaction{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
GenerateName: fmt.Sprintf("%s-merge-a-", syncObj.Name),
|
|
Namespace: namespace,
|
|
Labels: map[string]string{
|
|
"git.k8s.io/repo-sync": syncObj.Name,
|
|
"git.k8s.io/target": syncObj.Spec.RepoA.Name,
|
|
"git.k8s.io/merge": "true",
|
|
},
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
{
|
|
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
|
Kind: "GitRepoSync",
|
|
Name: syncObj.Name,
|
|
UID: syncObj.UID,
|
|
},
|
|
},
|
|
},
|
|
Spec: gitv1alpha1.GitPushTransactionSpec{
|
|
RepositoryRef: syncObj.Spec.RepoA.Name,
|
|
Atomic: true,
|
|
RefSpecs: []gitv1alpha1.PushRefSpec{
|
|
{
|
|
Source: mergeCommit,
|
|
Destination: fmt.Sprintf("refs/heads/%s", syncObj.Spec.BranchName),
|
|
ExpectedOldCommit: expectedOldA,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Push to repo B.
|
|
txnB := &gitv1alpha1.GitPushTransaction{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
GenerateName: fmt.Sprintf("%s-merge-b-", syncObj.Name),
|
|
Namespace: namespace,
|
|
Labels: map[string]string{
|
|
"git.k8s.io/repo-sync": syncObj.Name,
|
|
"git.k8s.io/target": syncObj.Spec.RepoB.Name,
|
|
"git.k8s.io/merge": "true",
|
|
},
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
{
|
|
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
|
Kind: "GitRepoSync",
|
|
Name: syncObj.Name,
|
|
UID: syncObj.UID,
|
|
},
|
|
},
|
|
},
|
|
Spec: gitv1alpha1.GitPushTransactionSpec{
|
|
RepositoryRef: syncObj.Spec.RepoB.Name,
|
|
Atomic: true,
|
|
RefSpecs: []gitv1alpha1.PushRefSpec{
|
|
{
|
|
Source: mergeCommit,
|
|
Destination: fmt.Sprintf("refs/heads/%s", syncObj.Spec.BranchName),
|
|
ExpectedOldCommit: expectedOldB,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if _, err := r.gitClient.GitPushTransactions(namespace).Create(ctx, txnA, metav1.CreateOptions{}); err != nil {
|
|
return fmt.Errorf("creating push transaction for repo A: %w", err)
|
|
}
|
|
|
|
if _, err := r.gitClient.GitPushTransactions(namespace).Create(ctx, txnB, metav1.CreateOptions{}); err != nil {
|
|
return fmt.Errorf("creating push transaction for repo B: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// markManualIntervention updates the GitRepoSync status to RequiresManualIntervention.
|
|
func (r *Reconciler) markManualIntervention(ctx context.Context, syncObj *gitv1alpha1.GitRepoSync, message string) error {
|
|
syncObj.Status.Phase = gitv1alpha1.SyncPhaseRequiresManualIntervention
|
|
syncObj.Status.Message = message
|
|
_, err := r.gitClient.GitRepoSyncs(syncObj.Namespace).UpdateStatus(ctx, syncObj, metav1.UpdateOptions{})
|
|
return err
|
|
}
|
|
|
|
func splitKey(key string) (string, string, error) {
|
|
for i := range key {
|
|
if key[i] == '/' {
|
|
return key[:i], key[i+1:], nil
|
|
}
|
|
}
|
|
return "", key, nil
|
|
}
|
|
|
|
var _ reconciler.LeaderAware = (*Reconciler)(nil)
|
|
|
|
func (r *Reconciler) Promote(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *Reconciler) Demote(bkt reconciler.Bucket) {
|
|
}
|