Skip to content

feat!: hold a repository branch with a GitBranch - #429

Merged
sunib merged 6 commits into
mainfrom
feat/gitbranch
Oct 21, 2026
Merged

sunib merged 6 commits into
mainfrom
feat/gitbranch

Conversation

@sunib

@sunib sunib commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of docs/design/gitbranch-and-kubeprovider-plan.md: a repository branch is its own object, GitBranch, and only the GitBranch that proves it can push holds it. This closes the gap #428 left open: with oldest-GitTarget-wins, any namespace could take a repository branch without a credential.

The shape

kind: GitBranch
metadata: {name: platform-main, namespace: platform}
spec:
  gitProviderRef: {name: platform}   # LocalObjectReference: the credential's namespace only
  branch: main
  parentBranch: develop              # optional
---
kind: GitTarget
metadata: {name: apps, namespace: team-a}
spec:
  gitBranchRef: {name: platform-main, namespace: platform}   # required, immutable
  path: apps
  • All of GitBranch.spec is immutable (CEL). GitTarget.spec.gitBranchRef is required and immutable.
  • GitTarget.spec.gitProviderRef, .branch and .parentBranch stay in the schema only so that setting one is refused, with a message naming gitBranchRef (the qps/allowAnySourceNamespace pattern).
  • status.remote moved to GitBranch. GitProvider.status.branches is gone; kubectl get gitbranch answers the same question.
  • Printer columns: Provider, Branch, Commit (status.remote.commit), Ready, Age. -o wide adds Reason, Repository, Targets, Verified and Message. The Commit column moves no faster than status.remote, which is written at most once a minute.

The branch claim (internal/git/branch_owner.go)

  1. A GitBranch becomes a candidate once its GitProvider's credential opens a receive-pack session on the remote (git.ProvePushSession: a handshake, then close, sending nothing).
  2. Among the candidates for one (normalized repo, branch), the oldest by creationTimestamp (then UID) holds the branch. Every other candidate reports Stalled=True, reason BranchConflict, naming the holder.
  3. The holder stays a candidate through a later proof failure: it reports Ready=False (PushSessionFailed) and keeps the branch and its pending writes.
  4. The claim lives in memory and is recomputed after a restart.

No admission webhook is involved, by decision: a conflicting GitBranch is admitted and reports a condition. Following rule 2 literally means an older GitBranch that proves itself later takes the branch over; that keeps the outcome the same across restarts.

The claim's three readers:

  • EnsureWorker refuses any GitBranch but the holder (ErrBranchOwnedElsewhere).
  • The sweep (workersNeeded) keeps exactly the holders' workers that a live GitTarget references.
  • The GitTarget reconciler wires a target only while its GitBranch holds the branch. Until then the target reports GitBranchReady=False (GitBranchNotReady) as progress, requeued on the not-Ready cadence.

git.BranchKey is unchanged. A worker now records the GitBranch it writes for. WritesThrough and the event router therefore route a target by its gitBranchRef, with no read on the event path. Credential, signing key and the provider_namespace/provider_name metric labels come from the holder's GitProvider.

What the proof proves

Measured against the e2e Gitea (1.27):

Credential receive-pack
none 401
wrong password 401
valid, read-only or non-collaborator 200
valid, writer 200

So on Gitea the proof means "a valid credential", not "may push", because of its AGit flow. GitHub and GitLab require push permission; a host that offers receive-pack anonymously proves only reachability. This was accepted as good enough, and configuration.md and UPGRADING.md say so per host. GitProvider readiness still uses upload-pack, which passes anonymously on a public repository, so this is a new, stronger check.

References are checked in status

Kind Reason When
GitTarget GitBranchNotFound no such GitBranch (Validated=False)
GitTarget GitBranchNotReady it exists but holds nothing, or is not Ready (progress)
GitTarget NamespaceNotAuthorized the GitBranch's GitProvider.accessFrom does not admit the target's namespace
GitBranch GitProviderNotFound, GitProviderNotReady, BranchNotAllowed moved from GitTarget
GitBranch ParentBranchNotFound, RecoveringParentBranch moved, rules unchanged
GitBranch PushSessionFailed, BranchConflict new

On the cross-namespace reason I kept NamespaceNotAuthorized: it is what a GitTarget reports today for exactly this check, and the plan says the existing reasons only change which kind reports them. GitProviderReady on GitTarget becomes GitBranchReady. WatchRule/ClusterWatchRule no longer read the GitProvider at all: they take the destination from the target's gitBranchRef, follow the target's readiness, and no longer report GitProviderNotFound.

Lifecycle

A GitBranch carries a finalizer. On deletion it releases the claim first, so no GitTarget reconcile can restart its worker. Then RetireGitBranch stops the worker; the shutdown path already finalizes the open window, pushes what is pending, and fails each unpushed CommitRequest with an outcome. Only then does the finalizer go. Nothing deletes the remote branch. The next candidate takes the branch once the old worker is gone.

status.remote on the GitBranch

The ledger and the one-minute floor are unchanged (gitbranch_remote.go, moved from gittarget_remote.go). What replaces requeueForRemoteAnswer's "asked" half is that the worker wakes its GitBranch (GitBranchEvents) every time it records an observation. The "held" half stays: a reconcile that meets news the floor holds back requeues for when it lifts. A successful proof is reused for one steady interval, so a reconcile woken by a push does not open a push session.

Commits

  1. docs: the plan.
  2. feat(api): the kind, additive (old fields temporarily optional).
  3. feat(git)!: claim, worker and reconcilers. The tests move to gitBranchRef, and ProvePushSession is tested against startRealGitServer, not file://.
  4. feat(api)!: old fields refused by name, gitBranchRef required, moved status removed.
  5. docs: reference docs, chart quick start (quickstart.gitBranch), samples, corpus fixtures, e2e. shared_gitprovider_e2e_test.go becomes shared_gitbranch_e2e_test.go and covers the "Done when" list.

One bug the tests caught along the way: the sweep compared needed[key] against a worker with no GitBranch, so two zero values compared equal and an orphan was kept. It now checks presence.

Validation

  • task lint, task test: green. Unit coverage is 79.8% against a baseline of 80.2% with 0.5% tolerance.
  • task test-e2e: not run locally. CI's e2e legs are the first run of the new e2e specs.

🤖 Generated with Claude Code

sunib and others added 5 commits October 21, 2026 18:29
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitBranch is one branch of a repository, written through a GitProvider in
its own namespace. GitTarget gains spec.gitBranchRef. Nothing reads either
yet; gitProviderRef and branch are optional until they leave the target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A GitBranch holds its repository branch once its GitProvider's credential
opens a receive-pack session on the remote; the oldest such GitBranch holds
it, keeps it through a later credential failure, and any other reports
BranchConflict. The claim replaces oldest-GitTarget-wins in branch_owner.go,
and EnsureWorker, the worker sweep and the GitTarget reconcile read it.

A worker now writes for the claiming GitBranch and uses its GitProvider's
credential, signing key and metric labels. GitTargets reach it through
spec.gitBranchRef; one in another namespace is admitted by the GitBranch's
GitProvider accessFrom. status.remote, ParentBranchNotFound and
RecoveringParentBranch move to the GitBranch, and its finalizer stops the
worker (pushing or refusing every pending write) before the claim goes.
GitProvider.status.branches is no longer published.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitTarget.spec.gitBranchRef is required. spec.gitProviderRef, spec.branch
and spec.parentBranch stay in the schema only so that setting one is
refused, naming gitBranchRef. GitTarget.status.remote is gone (it is on the
GitBranch), and so is GitProvider.status.branches: listing GitBranches
answers the same question.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…to it

The chart's quick start renders a GitBranch (quickstart.gitBranch), the
samples and corpus fixtures name one, and the reference docs describe the
branch claim, its reasons, and where status.remote now lives. The e2e suite
creates a GitBranch per provider branch; the shared-provider spec becomes a
shared-GitBranch spec covering the claim, and the parent-branch and remote
refresh specs read the GitBranch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 191 files, which is 91 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 96b80100-d4b5-4b03-9e46-e3e41b8ff33d
📥 Commits

Reviewing files that changed from the base of the PR and between c49803f and a508b92.

📒 Files selected for processing: 191
  • PROJECT
  • README.md
  • api/v1alpha3/examples_test.go
  • api/v1alpha3/gitbranch_types.go
  • api/v1alpha3/gitprovider_types.go
  • api/v1alpha3/gittarget_types.go
  • api/v1alpha3/status_accessors.go
  • api/v1alpha3/zz_generated.deepcopy.go
  • charts/gitops-reverser/README.md
  • charts/gitops-reverser/templates/NOTES.txt
  • charts/gitops-reverser/templates/quickstart.yaml
  • charts/gitops-reverser/templates/validate-quickstart.yaml
  • charts/gitops-reverser/values.schema.json
  • charts/gitops-reverser/values.yaml
  • cmd/main.go
  • config/crd/bases/configbutler.ai_gitbranches.yaml
  • config/crd/bases/configbutler.ai_gitproviders.yaml
  • config/crd/bases/configbutler.ai_gittargets.yaml
  • config/crd/kustomization.yaml
  • config/rbac/role.yaml
  • config/samples/README.md
  • config/samples/quickstart-gitbranch.yaml
  • config/samples/quickstart-gittarget.yaml
  • docs/UPGRADING.md
  • docs/architecture.md
  • docs/azure-devops-getting-started.md
  • docs/bi-directional-argocd.md
  • docs/bi-directional-flux.md
  • docs/configuration.md
  • docs/definitions.md
  • docs/demo/commit-window.md
  • docs/design/gitbranch-and-kubeprovider-plan.md
  • docs/facts/subresources.md
  • docs/interpreting-metrics.md
  • docs/layout/model.md
  • docs/layout/new-file-placement-rules.md
  • docs/quickstart.md
  • docs/spec/status-conditions-guide.md
  • hack/crdfields/fields.yaml
  • internal/applier/argocd_guide_test.go
  • internal/applier/dispatcher.go
  • internal/applier/dispatcher_test.go
  • internal/applier/flux_guide_test.go
  • internal/applier/references.go
  • internal/authz/clusterprovider_admission.go
  • internal/authz/gitbranch_admission_test.go
  • internal/authz/source_namespace_test.go
  • internal/controller/applytrigger_controller.go
  • internal/controller/applytrigger_controller_test.go
  • internal/controller/applytrigger_holder_test.go
  • internal/controller/applytrigger_rule_test.go
  • internal/controller/applytrigger_schema_test.go
  • internal/controller/clusterwatchrule_admission_test.go
  • internal/controller/clusterwatchrule_controller.go
  • internal/controller/commitrequest_outcome_metric_test.go
  • internal/controller/condition_helper_test.go
  • internal/controller/dependency_watches_test.go
  • internal/controller/duration_fields_admission_test.go
  • internal/controller/git_branch_helpers_test.go
  • internal/controller/gitbranch_controller.go
  • internal/controller/gitbranch_controller_test.go
  • internal/controller/gitbranch_remote.go
  • internal/controller/gitbranch_remote_test.go
  • internal/controller/gitprovider_branches.go
  • internal/controller/gitprovider_branches_test.go
  • internal/controller/gitprovider_controller.go
  • internal/controller/gitprovider_controller_test.go
  • internal/controller/gittarget_apply_trigger.go
  • internal/controller/gittarget_apply_trigger_test.go
  • internal/controller/gittarget_controller.go
  • internal/controller/gittarget_controller_test.go
  • internal/controller/gittarget_controller_unit_test.go
  • internal/controller/gittarget_gitbranch_test.go
  • internal/controller/gittarget_immutability_test.go
  • internal/controller/gittarget_layout_test.go
  • internal/controller/gittarget_parent_branch_test.go
  • internal/controller/gittarget_parent_recovery.go
  • internal/controller/gittarget_placement_validation_test.go
  • internal/controller/gittarget_publication.go
  • internal/controller/gittarget_publication_test.go
  • internal/controller/gittarget_shared_branch_test.go
  • internal/controller/gittarget_source_cluster.go
  • internal/controller/gittarget_source_cluster_test.go
  • internal/controller/gittarget_status_test.go
  • internal/controller/gittarget_worker_cleanup_test.go
  • internal/controller/gittarget_worker_repoint_test.go
  • internal/controller/live_message_migration_test.go
  • internal/controller/renamed_required_field_test.go
  • internal/controller/resource_condition_metrics.go
  • internal/controller/status_event_severity_test.go
  • internal/controller/stream_status_test.go
  • internal/controller/suite_test.go
  • internal/controller/superseded_fields_admission_test.go
  • internal/controller/watchrule_controller.go
  • internal/controller/watchrule_controller_test.go
  • internal/controller/watchrule_source_namespace.go
  • internal/controller/watchrule_source_namespace_test.go
  • internal/controller/wiring/readiness_wiring_test.go
  • internal/git/apply_recovery.go
  • internal/git/apply_request.go
  • internal/git/apply_trigger.go
  • internal/git/apply_trigger_test.go
  • internal/git/branch_owner.go
  • internal/git/branch_owner_test.go
  • internal/git/branch_worker.go
  • internal/git/branch_worker_loop_test.go
  • internal/git/branch_worker_queue_depth_test.go
  • internal/git/branch_worker_split_test.go
  • internal/git/branch_worker_test.go
  • internal/git/claim_helpers_test.go
  • internal/git/credentials.go
  • internal/git/git_atomic_push.go
  • internal/git/git_branch_ref.go
  • internal/git/git_branch_ref_test.go
  • internal/git/git_roundtrip_ledger_test.go
  • internal/git/layout_corpus_test.go
  • internal/git/prove_push_session_test.go
  • internal/git/publication.go
  • internal/git/publication_test.go
  • internal/git/refusal_reapply_test.go
  • internal/git/remote_report_test.go
  • internal/git/secret_write_test.go
  • internal/git/types.go
  • internal/git/types_test.go
  • internal/git/worker_branch_owner_test.go
  • internal/git/worker_manager.go
  • internal/git/worker_manager_test.go
  • internal/git/worker_repo_identity_test.go
  • internal/git/write_boundary_precondition_test.go
  • internal/rulestore/object_selector_test.go
  • internal/rulestore/store.go
  • internal/rulestore/store_test.go
  • internal/watch/api_resource_catalog_test.go
  • internal/watch/bootstrap.go
  • internal/watch/bootstrap_admission_test.go
  • internal/watch/collection_overlap_test.go
  • internal/watch/config_plane_split_review_fixes_test.go
  • internal/watch/event_router.go
  • internal/watch/event_router_test.go
  • internal/watch/git_branch_helpers_test.go
  • internal/watch/manager_snapshot_test.go
  • internal/watch/manager_startup_test.go
  • internal/watch/object_selector_test.go
  • internal/watch/source_namespace_planning_test.go
  • internal/watch/source_namespace_stream_summary_test.go
  • internal/watch/source_namespace_test.go
  • internal/watch/watched_type_helpers_test.go
  • internal/watch/watched_type_metrics_test.go
  • internal/watch/watched_type_resolver.go
  • internal/watch/watched_type_resolver_test.go
  • internal/watch/watchrule_compile.go
  • test/e2e/audit_route_attribution_e2e_test.go
  • test/e2e/flux_applier_e2e_test.go
  • test/e2e/flux_restart_recovery_e2e_test.go
  • test/e2e/foreign_content_e2e_test.go
  • test/e2e/gitprovider_validation_e2e_test.go
  • test/e2e/helmrelease_mirror_edit_e2e_test.go
  • test/e2e/helpers.go
  • test/e2e/inplace_edit_e2e_test.go
  • test/e2e/new_branch_parent_e2e_test.go
  • test/e2e/new_file_placement_e2e_test.go
  • test/e2e/prune_mode_e2e_test.go
  • test/e2e/quickstart_framework_e2e_test.go
  • test/e2e/refresh_remote_e2e_test.go
  • test/e2e/refusal_reapply_e2e_test.go
  • test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml
  • test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml
  • test/e2e/shared_gitbranch_e2e_test.go
  • test/e2e/shared_gitprovider_e2e_test.go
  • test/e2e/source_cluster_e2e_test.go
  • test/e2e/suspend_e2e_test.go
  • test/e2e/templates/gittarget.tmpl
  • test/e2e/templates/manager/gittarget-prune.tmpl
  • test/e2e/unsupported_folder_e2e_test.go
  • test/e2e/values_file_acceptance_e2e_test.go
  • test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
  • test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml
  • test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml
  • test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml
  • test/playground/config/gittarget.yaml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

A GitBranch stalled on BranchConflict is not reconciled again before its
steady requeue, so when the holder's finalizer released the branch the next
candidate held it in memory and reported BranchConflict for up to five
minutes. The finalizer now wakes it.

The e2e helpers label the GitBranches they create with their GitProvider,
and deleting a GitProvider in a spec's cleanup deletes them, so a spec's
GitBranch no longer holds the branch the next spec in the namespace writes.
The plan no longer links to a page this branch does not carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib
sunib merged commit 17c0c96 into main Oct 21, 2026
21 checks passed
@sunib
sunib deleted the feat/gitbranch branch October 21, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant