From ee281430b653052103d3c80d129c06bc338dc05b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:09:58 +0000 Subject: [PATCH] Add repo-watcher-controller for polling remote Git refs Introduces a fourth controller that watches GitRepository resources and polls their remotes via git ls-remote on a configurable per-repo interval (spec.pollInterval, defaulting to 30s). It auto-creates, updates, and deletes GitBranch CRDs to reflect the actual state of the remote, enabling fast detection of external pushes to trigger downstream sync. Key changes: - Add PollInterval field to GitRepositorySpec - New pkg/reconciler/repowatcher with controller and reconciler - New cmd/repo-watcher-controller binary - CRD manifest updated for pollInterval - Deployment manifest for the new controller - Unit tests for ref filtering, naming, and poll interval logic - CLAUDE.md updated with new controller docs and Go module workaround https://claude.ai/code/session_01P7xfBqARU5DFS8QJ4uDPVj --- CLAUDE.md | 24 +- cmd/repo-watcher-controller/main.go | 11 + .../git-k8s.imjasonh.com_gitrepositories.yaml | 3 + .../deployments/repo-watcher-controller.yaml | 51 ++++ pkg/apis/git/v1alpha1/types.go | 6 + .../git/v1alpha1/zz_generated.deepcopy.go | 6 + pkg/reconciler/repowatcher/controller.go | 78 ++++++ pkg/reconciler/repowatcher/reconciler.go | 254 ++++++++++++++++++ pkg/reconciler/repowatcher/reconciler_test.go | 121 +++++++++ 9 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 cmd/repo-watcher-controller/main.go create mode 100644 config/deployments/repo-watcher-controller.yaml create mode 100644 pkg/reconciler/repowatcher/controller.go create mode 100644 pkg/reconciler/repowatcher/reconciler.go create mode 100644 pkg/reconciler/repowatcher/reconciler_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dd5b29d..29edd12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ git-k8s is a Kubernetes-native controller system for managing Git repositories a ## Architecture -### Three-Controller Design +### Four-Controller Design 1. **Push Controller** (`cmd/push-controller/`) — Watches `GitPushTransaction` resources, clones repos into memory using go-git, executes atomic pushes, and updates transaction status through phases (Pending → InProgress → Succeeded/Failed). @@ -18,11 +18,13 @@ git-k8s is a Kubernetes-native controller system for managing Git repositories a 3. **Resolver Controller** (`cmd/resolver-controller/`) — Watches `GitRepoSync` resources in Conflicted phase. Performs automated 3-way merge with file-level conflict detection. Creates merge commits and push transactions to both repos. Falls back to `RequiresManualIntervention` on failure. +4. **Repo Watcher Controller** (`cmd/repo-watcher-controller/`) — Watches `GitRepository` resources and polls their remotes via `git ls-remote` on a configurable interval. Auto-creates, updates, and deletes `GitBranch` CRDs to reflect the actual state of the remote. Enables fast detection of external pushes to trigger downstream sync. + ### Custom Resources (CRDs) | CRD | Purpose | |-----|---------| -| `GitRepository` | Defines a managed Git repo (clone URL, default branch, auth) | +| `GitRepository` | Defines a managed Git repo (clone URL, default branch, auth, poll interval) | | `GitBranch` | Tracks a branch within a repo (head commit, last updated) | | `GitPushTransaction` | Atomic push operation with refspecs and CAS support | | `GitRepoSync` | Two-way sync relationship between two repositories | @@ -39,6 +41,7 @@ CRD manifests live in `config/crds/`. | `pkg/reconciler/push` | Push transaction reconciliation logic | | `pkg/reconciler/sync` | Repo sync reconciliation logic | | `pkg/reconciler/resolver` | Conflict resolution via 3-way merge | +| `pkg/reconciler/repowatcher` | Remote polling and GitBranch CRD lifecycle | ## Directory Structure @@ -47,6 +50,7 @@ cmd/ # Controller entry points push-controller/ sync-controller/ resolver-controller/ + repo-watcher-controller/ pkg/ apis/git/v1alpha1/ # CRD types, scheme, defaults, deepcopy client/ # Typed client wrapper (manual, not generated) @@ -55,6 +59,7 @@ pkg/ push/ # Push transaction controller + reconciler sync/ # Sync controller + reconciler resolver/ # Conflict resolver controller + reconciler + repowatcher/ # Remote polling + GitBranch lifecycle config/ crds/ # CRD YAML manifests rbac/ # RBAC role definitions @@ -80,6 +85,7 @@ hack/ # Code generation scripts go build ./cmd/push-controller/ go build ./cmd/sync-controller/ go build ./cmd/resolver-controller/ +go build ./cmd/repo-watcher-controller/ # Run unit tests go test ./... @@ -151,3 +157,17 @@ GitHub Actions (`.github/workflows/ci.yaml`) runs two jobs: 2. **E2E** — Sets up KinD cluster, installs CRDs, deploys controllers via `ko`, deploys Gitea, runs e2e tests with `-tags=e2e` Container images use `ko` with a `gcr.io/distroless/static:nonroot` base image (configured in `.ko.yaml`). + +## Go Module Download Workaround + +In some CI/cloud environments, `go mod download` (and `go build`, `go test`, etc.) may fail with DNS resolution errors for `storage.googleapis.com`. This happens when the `no_proxy` / `NO_PROXY` environment variable includes `*.googleapis.com`, causing Go's HTTP client to bypass the egress proxy and attempt direct DNS resolution, which fails. + +**Fix:** Strip `*.googleapis.com` from `no_proxy` so that requests to `storage.googleapis.com` (where the Go module proxy stores zip files) route through the egress proxy: + +```bash +NO_PROXY=$(echo "$no_proxy" | sed 's/,\*\.googleapis\.com//; s/\*\.googleapis\.com,//; s/\*\.googleapis\.com//') \ +no_proxy=$(echo "$no_proxy" | sed 's/,\*\.googleapis\.com//; s/\*\.googleapis\.com,//; s/\*\.googleapis\.com//') \ + go mod download +``` + +Apply the same prefix to `go build`, `go test`, `go vet`, etc. when downloading new modules. diff --git a/cmd/repo-watcher-controller/main.go b/cmd/repo-watcher-controller/main.go new file mode 100644 index 0000000..83a3d97 --- /dev/null +++ b/cmd/repo-watcher-controller/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "knative.dev/pkg/injection/sharedmain" + + "github.com/imjasonh/git-k8s/pkg/reconciler/repowatcher" +) + +func main() { + sharedmain.Main("repo-watcher-controller", repowatcher.NewController) +} diff --git a/config/crds/git-k8s.imjasonh.com_gitrepositories.yaml b/config/crds/git-k8s.imjasonh.com_gitrepositories.yaml index e2da1d5..ddf72f7 100644 --- a/config/crds/git-k8s.imjasonh.com_gitrepositories.yaml +++ b/config/crds/git-k8s.imjasonh.com_gitrepositories.yaml @@ -46,6 +46,9 @@ spec: type: string required: - name + pollInterval: + type: string + description: How often to poll the remote for ref changes (e.g., "5s", "30s", "1m"). status: type: object properties: diff --git a/config/deployments/repo-watcher-controller.yaml b/config/deployments/repo-watcher-controller.yaml new file mode 100644 index 0000000..b411274 --- /dev/null +++ b/config/deployments/repo-watcher-controller.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: repo-watcher-controller + namespace: git-system + labels: + app.kubernetes.io/name: git-k8s + app.kubernetes.io/component: repo-watcher-controller +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/component: repo-watcher-controller + template: + metadata: + labels: + app.kubernetes.io/name: git-k8s + app.kubernetes.io/component: repo-watcher-controller + spec: + serviceAccountName: git-k8s-controller + containers: + - name: controller + # ko will replace this with the actual registry image SHA. + image: ko://github.com/imjasonh/git-k8s/cmd/repo-watcher-controller + env: + - name: SYSTEM_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: CONFIG_LOGGING_NAME + value: config-logging + - name: CONFIG_OBSERVABILITY_NAME + value: config-observability + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL diff --git a/pkg/apis/git/v1alpha1/types.go b/pkg/apis/git/v1alpha1/types.go index a994ba1..5a9abe4 100644 --- a/pkg/apis/git/v1alpha1/types.go +++ b/pkg/apis/git/v1alpha1/types.go @@ -29,6 +29,12 @@ type GitRepositorySpec struct { // Auth contains authentication configuration for the repository. // +optional Auth *GitAuth `json:"auth,omitempty"` + + // PollInterval is how often to poll the remote for ref changes. + // Specified as a duration string (e.g., "5s", "30s", "1m"). + // If unset, the controller's default poll interval is used. + // +optional + PollInterval *metav1.Duration `json:"pollInterval,omitempty"` } // GitAuth contains authentication details for accessing a Git repository. diff --git a/pkg/apis/git/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/git/v1alpha1/zz_generated.deepcopy.go index 40506d4..e2d6410 100644 --- a/pkg/apis/git/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/git/v1alpha1/zz_generated.deepcopy.go @@ -6,6 +6,7 @@ package v1alpha1 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -79,6 +80,11 @@ func (in *GitRepositorySpec) DeepCopyInto(out *GitRepositorySpec) { *out = new(GitAuth) (*in).DeepCopyInto(*out) } + if in.PollInterval != nil { + in, out := &in.PollInterval, &out.PollInterval + *out = new(metav1.Duration) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepositorySpec. diff --git a/pkg/reconciler/repowatcher/controller.go b/pkg/reconciler/repowatcher/controller.go new file mode 100644 index 0000000..a146d40 --- /dev/null +++ b/pkg/reconciler/repowatcher/controller.go @@ -0,0 +1,78 @@ +package repowatcher + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/injection" + "knative.dev/pkg/logging" + + gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" + gitclient "github.com/imjasonh/git-k8s/pkg/client" + "github.com/imjasonh/git-k8s/pkg/reconciler/internal" +) + +var repoGVR = schema.GroupVersionResource{ + Group: gitv1alpha1.GroupName, + Version: gitv1alpha1.Version, + Resource: "gitrepositories", +} + +// NewController creates a new controller for watching GitRepository remotes. +func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + logger := logging.FromContext(ctx) + + cfg := injection.GetConfig(ctx) + dynClient, err := dynamic.NewForConfig(cfg) + if err != nil { + logger.Fatalf("Error building dynamic client: %v", err) + } + + gitClient := gitclient.NewFromDynamic(dynClient) + + factory := dynamicinformer.NewDynamicSharedInformerFactory(dynClient, 30*time.Second) + repoInformer := factory.ForResource(repoGVR) + + r := &Reconciler{ + dynamicClient: dynClient, + gitClient: gitClient, + defaultInterval: DefaultPollInterval, + lsRemote: defaultLsRemote, + } + + impl := controller.NewContext(ctx, internal.NewReconciler( + func(ctx context.Context, namespace, name string) (*gitv1alpha1.GitRepository, error) { + return gitClient.GitRepositories(namespace).Get(ctx, name, metav1.GetOptions{}) + }, + r, + ), controller.ControllerOptions{ + WorkQueueName: "RepoWatcher", + Logger: logger, + }) + + // Give the reconciler access to the impl for re-enqueue. + r.SetImpl(impl) + + logger.Info("Setting up event handlers for repo-watcher") + + repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + impl.Enqueue(obj) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + impl.Enqueue(newObj) + }, + }) + + factory.Start(ctx.Done()) + factory.WaitForCacheSync(ctx.Done()) + + return impl +} diff --git a/pkg/reconciler/repowatcher/reconciler.go b/pkg/reconciler/repowatcher/reconciler.go new file mode 100644 index 0000000..d91d1f7 --- /dev/null +++ b/pkg/reconciler/repowatcher/reconciler.go @@ -0,0 +1,254 @@ +package repowatcher + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "k8s.io/apimachinery/pkg/types" + + "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/memory" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + "knative.dev/pkg/controller" + "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" +) + +// DefaultPollInterval is the default interval between remote ref polls. +const DefaultPollInterval = 30 * time.Second + +// Reconciler watches remote Git repositories and keeps GitBranch CRDs in sync. +type Reconciler struct { + dynamicClient dynamic.Interface + gitClient *gitclient.GitV1alpha1Client + defaultInterval time.Duration + impl *controller.Impl + + // lsRemote is overridable for testing. + lsRemote func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) +} + +// SetImpl stores the controller.Impl so the reconciler can re-enqueue with a delay. +func (r *Reconciler) SetImpl(impl *controller.Impl) { + r.impl = impl +} + +// ReconcileKind polls the remote for a single GitRepository and syncs GitBranch CRDs. +func (r *Reconciler) ReconcileKind(ctx context.Context, repo *gitv1alpha1.GitRepository) reconciler.Event { + logger := logging.FromContext(ctx) + namespace := repo.Namespace + + // Check if enough time has passed since the last fetch. + interval := r.pollInterval(repo) + if repo.Status.LastFetchTime != nil { + elapsed := time.Since(repo.Status.LastFetchTime.Time) + if elapsed < interval { + r.enqueueAfter(repo, interval-elapsed) + return nil + } + } + + // Resolve auth credentials. + auth, err := r.resolveAuth(ctx, namespace, repo) + if err != nil { + return fmt.Errorf("resolving auth for %s/%s: %w", namespace, repo.Name, err) + } + + // Run ls-remote to discover all remote refs. + refs, err := r.lsRemote(repo.Spec.URL, auth) + if err != nil { + return fmt.Errorf("ls-remote for %s: %w", repo.Spec.URL, err) + } + + // Build a map of branch name -> commit SHA from remote refs. + remoteBranches := make(map[string]string) + for _, ref := range refs { + name := ref.Name() + if name.IsBranch() { + branchName := name.Short() + remoteBranches[branchName] = ref.Hash().String() + } + } + + // List existing GitBranch CRDs for this repo. + existingBranches, err := r.gitClient.GitBranches(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing branches: %w", err) + } + + // Index existing branches by branch name for this repo. + existingByName := make(map[string]*gitv1alpha1.GitBranch) + for i := range existingBranches.Items { + b := &existingBranches.Items[i] + if b.Spec.RepositoryRef == repo.Name { + existingByName[b.Spec.BranchName] = b + } + } + + // Create or update branches found on the remote. + now := metav1.Now() + for branchName, commitSHA := range remoteBranches { + existing, found := existingByName[branchName] + if !found { + // Create new GitBranch. + newBranch := &gitv1alpha1.GitBranch{ + ObjectMeta: metav1.ObjectMeta{ + Name: branchCRDName(repo.Name, branchName), + Namespace: namespace, + Labels: map[string]string{ + "git-k8s.imjasonh.com/repository": repo.Name, + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: gitv1alpha1.SchemeGroupVersion.String(), + Kind: "GitRepository", + Name: repo.Name, + UID: repo.UID, + }, + }, + }, + Spec: gitv1alpha1.GitBranchSpec{ + RepositoryRef: repo.Name, + BranchName: branchName, + }, + } + created, err := r.gitClient.GitBranches(namespace).Create(ctx, newBranch, metav1.CreateOptions{}) + if err != nil { + if apierrors.IsAlreadyExists(err) { + logger.Debugf("Branch %s already exists, will update on next poll", branchName) + continue + } + return fmt.Errorf("creating branch %s: %w", branchName, err) + } + // Set the status on the newly created branch. + created.Status.HeadCommit = commitSHA + created.Status.LastUpdated = &now + if _, err := r.gitClient.GitBranches(namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating status for new branch %s: %w", branchName, err) + } + logger.Infof("Created GitBranch %s/%s (commit: %s)", namespace, created.Name, commitSHA[:minLen(len(commitSHA), 7)]) + } else if existing.Status.HeadCommit != commitSHA { + // Update existing branch with new commit. + existing.Status.HeadCommit = commitSHA + existing.Status.LastUpdated = &now + if _, err := r.gitClient.GitBranches(namespace).UpdateStatus(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating branch %s: %w", branchName, err) + } + logger.Infof("Updated GitBranch %s/%s to commit %s", namespace, existing.Name, commitSHA[:minLen(len(commitSHA), 7)]) + } + } + + // Delete branches that no longer exist on the remote. + for branchName, existing := range existingByName { + if _, found := remoteBranches[branchName]; !found { + if err := r.gitClient.GitBranches(namespace).Delete(ctx, existing.Name, metav1.DeleteOptions{}); err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting branch %s: %w", existing.Name, err) + } + } + logger.Infof("Deleted GitBranch %s/%s (branch %s removed from remote)", namespace, existing.Name, branchName) + } + } + + // Update the repo's LastFetchTime. + repo.Status.LastFetchTime = &now + if _, err := r.gitClient.GitRepositories(namespace).UpdateStatus(ctx, repo, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating repo status: %w", err) + } + + // Re-enqueue for the next poll cycle. + r.enqueueAfter(repo, interval) + + return nil +} + +// pollInterval returns the effective poll interval for a repository. +func (r *Reconciler) pollInterval(repo *gitv1alpha1.GitRepository) time.Duration { + if repo.Spec.PollInterval != nil { + return repo.Spec.PollInterval.Duration + } + return r.defaultInterval +} + +// enqueueAfter schedules the repository for reconciliation after the given delay. +func (r *Reconciler) enqueueAfter(repo *gitv1alpha1.GitRepository, delay time.Duration) { + if r.impl != nil { + r.impl.EnqueueKeyAfter(types.NamespacedName{ + Namespace: repo.Namespace, + Name: repo.Name, + }, delay) + } +} + +// resolveAuth retrieves Git authentication credentials from the referenced Secret. +func (r *Reconciler) resolveAuth(ctx context.Context, namespace string, repo *gitv1alpha1.GitRepository) (*http.BasicAuth, error) { + if repo.Spec.Auth == nil || repo.Spec.Auth.SecretRef == nil { + return nil, nil + } + + secretGVR := corev1.SchemeGroupVersion.WithResource("secrets") + u, err := r.dynamicClient.Resource(secretGVR).Namespace(namespace).Get(ctx, repo.Spec.Auth.SecretRef.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("getting secret %q: %w", repo.Spec.Auth.SecretRef.Name, err) + } + + data, found, err := unstructured.NestedStringMap(u.Object, "data") + if err != nil || !found { + return nil, fmt.Errorf("reading secret data: found=%v err=%v", found, err) + } + + username, err := base64.StdEncoding.DecodeString(data["username"]) + if err != nil { + return nil, fmt.Errorf("decoding username: %w", err) + } + password, err := base64.StdEncoding.DecodeString(data["password"]) + if err != nil { + return nil, fmt.Errorf("decoding password: %w", err) + } + + return &http.BasicAuth{ + Username: string(username), + Password: string(password), + }, nil +} + +// defaultLsRemote performs a real git ls-remote using go-git. +func defaultLsRemote(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + remote := git.NewRemote(memory.NewStorage(), &gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{url}, + }) + + listOpts := &git.ListOptions{} + if auth != nil { + listOpts.Auth = auth + } + return remote.List(listOpts) +} + +// branchCRDName generates a deterministic name for a GitBranch CR. +func branchCRDName(repoName, branchName string) string { + // Replace slashes with dashes for K8s name compatibility. + safeBranch := strings.ReplaceAll(branchName, "/", "-") + return fmt.Sprintf("%s-%s", repoName, safeBranch) +} + +func minLen(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/reconciler/repowatcher/reconciler_test.go b/pkg/reconciler/repowatcher/reconciler_test.go new file mode 100644 index 0000000..df96570 --- /dev/null +++ b/pkg/reconciler/repowatcher/reconciler_test.go @@ -0,0 +1,121 @@ +package repowatcher + +import ( + "testing" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport/http" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" +) + +func TestBranchCRDName(t *testing.T) { + tests := []struct { + repo string + branch string + want string + }{ + {"my-repo", "main", "my-repo-main"}, + {"my-repo", "feature/foo", "my-repo-feature-foo"}, + {"my-repo", "release/v1.0", "my-repo-release-v1.0"}, + } + for _, tt := range tests { + t.Run(tt.branch, func(t *testing.T) { + got := branchCRDName(tt.repo, tt.branch) + if got != tt.want { + t.Errorf("branchCRDName(%q, %q) = %q, want %q", tt.repo, tt.branch, got, tt.want) + } + }) + } +} + +func TestPollInterval(t *testing.T) { + r := &Reconciler{defaultInterval: 30 * time.Second} + + // No override — use default. + repo := &gitv1alpha1.GitRepository{} + if got := r.pollInterval(repo); got != 30*time.Second { + t.Errorf("pollInterval (default) = %v, want 30s", got) + } + + // Per-repo override. + d := metav1.Duration{Duration: 5 * time.Second} + repo.Spec.PollInterval = &d + if got := r.pollInterval(repo); got != 5*time.Second { + t.Errorf("pollInterval (override) = %v, want 5s", got) + } +} + +func TestMinLen(t *testing.T) { + if got := minLen(10, 7); got != 7 { + t.Errorf("minLen(10, 7) = %d, want 7", got) + } + if got := minLen(3, 7); got != 3 { + t.Errorf("minLen(3, 7) = %d, want 3", got) + } +} + +func TestMockLsRemote(t *testing.T) { + // Verify the mock lsRemote pattern works. + refs := []*plumbing.Reference{ + plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("aaaa")), + plumbing.NewHashReference(plumbing.NewBranchReferenceName("feature/foo"), plumbing.NewHash("bbbb")), + plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0"), plumbing.NewHash("cccc")), + plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main")), + } + + mockLsRemote := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + return refs, nil + } + + got, err := mockLsRemote("http://example.com/repo.git", nil) + if err != nil { + t.Fatalf("mockLsRemote() error = %v", err) + } + + // Count branch refs. + branches := 0 + for _, ref := range got { + if ref.Name().IsBranch() { + branches++ + } + } + if branches != 2 { + t.Errorf("branch count = %d, want 2", branches) + } +} + +func TestRefFiltering(t *testing.T) { + // Simulate what the reconciler does: filter ls-remote output to branches only. + refs := []*plumbing.Reference{ + plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("aaaa")), + plumbing.NewHashReference(plumbing.NewBranchReferenceName("develop"), plumbing.NewHash("bbbb")), + plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0"), plumbing.NewHash("cccc")), + plumbing.NewHashReference(plumbing.NewRemoteReferenceName("origin", "main"), plumbing.NewHash("dddd")), + plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main")), + } + + remoteBranches := make(map[string]string) + for _, ref := range refs { + name := ref.Name() + if name.IsBranch() { + remoteBranches[name.Short()] = ref.Hash().String() + } + } + + if len(remoteBranches) != 2 { + t.Errorf("filtered branches = %d, want 2", len(remoteBranches)) + } + if _, ok := remoteBranches["main"]; !ok { + t.Error("missing branch 'main'") + } + if _, ok := remoteBranches["develop"]; !ok { + t.Error("missing branch 'develop'") + } + // Tags and remote refs should not be included. + if _, ok := remoteBranches["v1.0"]; ok { + t.Error("tag v1.0 should not be in branches") + } +}