1
0
Fork 0
mirror of https://github.com/imjasonh/git-k8s synced 2026-07-12 08:28:38 +00:00
git-k8s/pkg/reconciler/resolver/resolver.go
Claude b122fb297f
Refactor reconcilers to use Knative ReconcileKind pattern
Move the key-splitting, resource-fetching, and not-found handling into a
generic internal.NewReconciler wrapper so that each reconciler only
implements ReconcileKind(ctx, *TypedResource) — matching the Knative
generated reconciler convention. The controller setup wraps the
reconciler with internal.NewReconciler, which bridges Reconcile(ctx, key)
to ReconcileKind(ctx, *T).

This removes duplicated boilerplate from all three reconcilers (push,
sync, resolver), eliminates their Reconcile/LeaderAware methods, and
makes splitKey an unexported implementation detail.

https://claude.ai/code/session_01Eu3LyxX3G1KCcw4aDzmHvp
2026-02-28 05:06:22 +00:00

336 lines
11 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/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
}
// ReconcileKind processes a single GitRepoSync resource that is in the Conflicted phase.
func (r *Reconciler) ReconcileKind(ctx context.Context, syncObj *gitv1alpha1.GitRepoSync) reconciler.Event {
logger := logging.FromContext(ctx)
namespace := syncObj.Namespace
// Only process if still conflicted.
if syncObj.Status.Phase != gitv1alpha1.SyncPhaseConflicted {
return nil
}
logger.Infof("Attempting to resolve conflict for %s/%s", namespace, syncObj.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.
// If there are no file-level conflicts (disjoint changes), it builds a merged tree
// that includes changes from both branches and creates a merge commit.
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. Build a merged tree that starts from A's tree
// (which already has A's changes) and applies B's changes on top.
logger.Info("No file-level conflicts detected, building merged tree")
mergedTreeHash, err := buildMergedTree(storer, treeA, treeB, diffB)
if err != nil {
return "", fmt.Errorf("building merged tree: %w", err)
}
now := time.Now()
mergeCommit := &object.Commit{
Author: object.Signature{
Name: "git-k8s-resolver",
Email: "resolver@git-k8s.imjasonh.com",
When: now,
},
Committer: object.Signature{
Name: "git-k8s-resolver",
Email: "resolver@git-k8s.imjasonh.com",
When: now,
},
Message: fmt.Sprintf("Merge %s and %s\n\nAutomated merge by git-k8s conflict resolver.", commitAHash[:8], commitBHash[:8]),
TreeHash: mergedTreeHash,
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
}
// buildMergedTree constructs a tree that contains A's tree with B's non-conflicting
// changes applied. It processes diffB to apply additions, modifications, and deletions
// from branch B onto tree A.
func buildMergedTree(storer *memory.Storage, treeA, treeB *object.Tree, diffB object.Changes) (plumbing.Hash, error) {
// Start with all entries from tree A.
entries := make(map[string]object.TreeEntry)
for _, entry := range treeA.Entries {
entries[entry.Name] = entry
}
// Apply B's changes.
for _, change := range diffB {
if change.To.Name != "" {
// File added or modified in B: use B's version.
entry, err := treeB.FindEntry(change.To.Name)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("finding entry %q in tree B: %w", change.To.Name, err)
}
entries[change.To.Name] = *entry
}
if change.From.Name != "" && change.To.Name == "" {
// File deleted in B: remove from merged tree.
delete(entries, change.From.Name)
}
if change.From.Name != "" && change.To.Name != "" && change.From.Name != change.To.Name {
// File renamed in B: remove old name.
delete(entries, change.From.Name)
}
}
// Build sorted tree entries.
sortedEntries := make([]object.TreeEntry, 0, len(entries))
for _, entry := range entries {
sortedEntries = append(sortedEntries, entry)
}
mergedTree := &object.Tree{Entries: sortedEntries}
encodedObj := storer.NewEncodedObject()
if err := mergedTree.Encode(encodedObj); err != nil {
return plumbing.ZeroHash, fmt.Errorf("encoding merged tree: %w", err)
}
hash, err := storer.SetEncodedObject(encodedObj)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("storing merged tree: %w", err)
}
return hash, 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.imjasonh.com/repo-sync": syncObj.Name,
"git-k8s.imjasonh.com/target": syncObj.Spec.RepoA.Name,
"git-k8s.imjasonh.com/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.imjasonh.com/repo-sync": syncObj.Name,
"git-k8s.imjasonh.com/target": syncObj.Spec.RepoB.Name,
"git-k8s.imjasonh.com/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
}