1
0
Fork 0
mirror of https://github.com/imjasonh/git-k8s synced 2026-07-13 08:57:00 +00:00
git-k8s/pkg/reconciler/resolver/resolver.go
Claude ffc7c82ae4
Fix LeaderAware interface: use correct Promote signature
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
2026-02-28 00:21:24 +00:00

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) {
}