Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions api/v1alpha3/gitbranch_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ type GitBranchSpec struct {

// ParentBranch is the branch spec.branch is created from, and compared
// against while spec.branch does not exist on the remote. Omitted, it is the
// remote's default branch, or no history at all in an empty repository.
// remote's default branch.
//
// In an empty repository with this omitted, a spec.branch that is not the
// default branch starts from it: the default branch is created first, with
// one empty commit, when spec.allowedGitBranches lets this GitBranch write
// it. Otherwise this GitBranch reports Stalled=True, reason
// DefaultBranchCreationNotAllowed, and writes nothing. A server that does
// not name its default branch (Git protocol v0) gets spec.branch as its
// first branch.
//
// When set, it must exist on the remote for spec.branch to be created;
// otherwise this GitBranch reports Stalled=True, reason ParentBranchNotFound,
Expand Down Expand Up @@ -171,7 +179,8 @@ const (
// branch it carries.
GitBranchParentMissing GitBranchParentState = "Missing"
// GitBranchParentUnborn means the repository is empty, with no refs at all (tags included), and
// no parent is configured, so the first commit starts the branch with no history.
// no parent is configured. The first commit starts the branch with no history when it is the
// default branch; otherwise the default branch is created first, with one empty commit.
GitBranchParentUnborn GitBranchParentState = "Unborn"
)

Expand All @@ -180,14 +189,15 @@ const (
type GitBranchParentStatus struct {
// State is what the branch would be created from: "Found" (the parent branch's
// tip), "Missing" (the parent branch is not on the remote, so nothing is
// written) or "Unborn" (an empty repository and no spec.parentBranch, so the
// first commit has no history).
// written) or "Unborn" (an empty repository and no spec.parentBranch: the
// default branch is created first, unless it is spec.branch).
// +required
State GitBranchParentState `json:"state"`

// Branch is the parent branch: spec.parentBranch, or the remote's default
// branch when that is omitted. Empty when state is "Unborn", and when the
// remote names no default branch.
// branch when that is omitted. When state is "Unborn" it is the branch the
// empty repository's HEAD names, which only a server speaking Git protocol
// v2 reports. Empty when the remote names no default branch.
// +optional
Branch string `json:"branch,omitempty"`

Expand Down
19 changes: 14 additions & 5 deletions config/crd/bases/configbutler.ai_gitbranches.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,15 @@ spec:
description: |-
ParentBranch is the branch spec.branch is created from, and compared
against while spec.branch does not exist on the remote. Omitted, it is the
remote's default branch, or no history at all in an empty repository.
remote's default branch.

In an empty repository with this omitted, a spec.branch that is not the
default branch starts from it: the default branch is created first, with
one empty commit, when spec.allowedGitBranches lets this GitBranch write
it. Otherwise this GitBranch reports Stalled=True, reason
DefaultBranchCreationNotAllowed, and writes nothing. A server that does
not name its default branch (Git protocol v0) gets spec.branch as its
first branch.

When set, it must exist on the remote for spec.branch to be created;
otherwise this GitBranch reports Stalled=True, reason ParentBranchNotFound,
Expand Down Expand Up @@ -391,8 +399,9 @@ spec:
branch:
description: |-
Branch is the parent branch: spec.parentBranch, or the remote's default
branch when that is omitted. Empty when state is "Unborn", and when the
remote names no default branch.
branch when that is omitted. When state is "Unborn" it is the branch the
empty repository's HEAD names, which only a server speaking Git protocol
v2 reports. Empty when the remote names no default branch.
type: string
commit:
description: |-
Expand All @@ -403,8 +412,8 @@ spec:
description: |-
State is what the branch would be created from: "Found" (the parent branch's
tip), "Missing" (the parent branch is not on the remote, so nothing is
written) or "Unborn" (an empty repository and no spec.parentBranch, so the
first commit has no history).
written) or "Unborn" (an empty repository and no spec.parentBranch: the
default branch is created first, unless it is spec.branch).
enum:
- Found
- Missing
Expand Down
2 changes: 1 addition & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ so you never have to read a proposal to find out it already shipped.
| [`gittarget-branch-worker-pending-writes.md`](design/gittarget-branch-worker-pending-writes.md) | **built in #413.** Ordered pending writes, one materializer and one retry schedule; a safe admission boundary for the watch; Git call deadlines; intake pause and resume; publication status. Holds the recovery contract for an outage, replay limits, and the acceptance scenarios. |
| [`gittarget-parent-branch.md`](design/gittarget-parent-branch.md) | **merged in #407; the field moved to `GitBranch` in #429.** `GitBranch.spec.parentBranch`: a branch stays on standby until a write needs it, then is created from the parent's tip as checked at publication. The contract as built, the defect it started from, prior art and naming. Open: reporting the parent per branch in status. |
| [`gittarget-parent-hardening.md`](design/gittarget-parent-hardening.md) | **done in #407.** Parent changes on a live worker, an empty repository gaining a ref, an unresolved default branch, recovery that cannot strand work, and the refused upload, without adding a pull to the fast path; plus the two recovery fixes from the review at `93120caf`. Records the merge boundary and the next separate PRs. |
| [`gittarget-parent-empty-repository.md`](design/gittarget-parent-empty-repository.md) | **deferred.** An empty repository's default branch: the server reports it over protocol v2, and go-git discarded it at alpha.5. The pinned beta.1 keeps it at the transport layer (`RemoteRefs.Unborn`), so the go-git half may be unblocked (see [TODO](TODO.md)); which branch the first commit may create stays a separate policy decision. Assuming `main` was considered and rejected. |
| [`gittarget-parent-empty-repository.md`](design/gittarget-parent-empty-repository.md) | **built in #480.** An empty repository's first commit: status names the default branch (protocol v2), and a write branch that is not the default starts from it, created first with one empty commit when `allowedGitBranches` allows it, else `DefaultBranchCreationNotAllowed`. Why, and what was deliberately not verified. |
| [`gittarget-parent-observation.md`](design/gittarget-parent-observation.md) | **deferred.** Keep the parent visible after the write branch exists: availability first, ancestry only after measuring how often a shallow checkout can answer it. Informational; authorizes no branch mutation. |
| [`gittarget-red-status-plan.md`](design/gittarget-red-status-plan.md) | **design, partly built.** The smaller alternative to Git write preflight: make a `GitTarget` turn red and explain why no commit was made, without moving Git checks into the admission webhook and without adding a second state source. Keeps the existing kstatus trio and domain conditions, ranks the most likely operator-facing failures, and has now shipped the first status-transition slice: refusal messages republish when details change, scoped refusals cannot be cleared by unrelated scope success, `BlockMessage()` carries stable issue kind/path/actor detail, and the unsupported-folder e2e covers Git-side break and recovery. |
| [`open-asks-priority.md`](design/open-asks-priority.md) | **stale; [`TODO.md`](TODO.md) is the work queue.** Merged three overlapping backlogs (the downstream consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal B1–B6) into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. B2, B4, F6 and F10 have shipped, and F9 is probably moot since #333 deleted the `ClusterWatchRule` `scope` field. Still the record of one design call: **Option C sibling inference was deleted** rather than given an off-switch, and "what the deletion taught" records what building it found |
Expand Down
14 changes: 5 additions & 9 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,11 @@ Their argument for each is in [gitops-api-open-asks.md](future/gitops-api-open-a
`GitTarget`, plus the durable queue, plus whether a replica warms its fact index before starting a
watch it took over. Redis becomes required in this mode.
[ha-gittarget-distribution-plan.md](future/ha-gittarget-distribution-plan.md).
- [ ] **Learn an empty repository's default branch, and simplify around it.** The pinned go-git
(`v6.0.0-beta.1`) now keeps what protocol v2 `ls-refs` reports about an unborn `HEAD`
(`transport.RemoteRefs.Unborn`); at alpha.5 it was discarded, which is why status reports
`unknown` and the code carries `ParentUnborn`, `ErrDefaultBranchUnresolved` and the
`branch_worker_empty_repo_test.go` pins. Check whether `Remote.List` exposes it or only the
transport session does, read it in `refresh.go` and `git_smart_fetch.go`, and remove what it makes
unnecessary. Which branch a first commit may create stays a separate policy decision
([gittarget-parent-empty-repository.md](design/gittarget-parent-empty-repository.md); its §4 and
the go-git entry in [misconceptions.md](misconceptions.md) need updating with it).
- [ ] **Drop `unbornHead` once go-git reports an empty repository's unborn HEAD.** Upstream main
(`d6fa0275`) adds `LsRefsOutput.UnbornHead` but `GetRemoteRefs` still returns
`ErrEmptyRemoteRepository` before building `RemoteRefs`. An upstream PR returning the name there,
or on the error (an `EmptyRemoteRepositoryError{Unborn}` whose `Is(ErrEmptyRemoteRepository)` is
true, from both `GetRemoteRefs` v2 paths), would let `listRemoteRefs` go back to `Remote.List`.
- [ ] **The branch claim is first-proven-wins** (`internal/git/branch_owner.go`). Anyone who can
create a `GitProvider` and a `GitBranch` with a credential that passes the push-session proof can
claim a branch no older `GitBranch` holds; on Gitea and Forgejo a read-only credential passes it
Expand Down
26 changes: 26 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ guidance that the changelog's breaking-change entries link to.
We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with
`bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it.

## An empty repository gets its default branch first

In an empty repository with `spec.parentBranch` omitted, a `GitBranch` whose `spec.branch` is not
the repository's default branch no longer creates `spec.branch` as the repository's first branch.
The default branch the server names (`main` on GitHub, Gitea and Forgejo) is created first, with one
empty commit authored and signed like the provider's other commits, as soon as the worker picks up
work for the branch, and `spec.branch` starts from it once there is a change. On Gitea and Forgejo
the first branch pushed became the default branch, so a review branch used to become the default,
and later branches built on it.

That is a write to the default branch, so the provider's `spec.allowedGitBranches` must allow it for
the `GitBranch`'s namespace. If it does not, the `GitBranch` reports `Ready=False`, `Stalled=True`,
reason `DefaultBranchCreationNotAllowed`, its `GitTarget`s report `GitBranchNotReady`, and nothing is
written until you allow the branch or push a first commit to it:

```yaml
allowedGitBranches:
- consumerNamespaces: {from: Same}
branchPatterns: ["main", "reverser/edits"]
```

A `GitBranch` whose `spec.branch` is the default branch, as in the quickstart, is unaffected. So is
a server that does not name its default branch (Git protocol v0), where `spec.branch` is still
created as the first branch. `status.remote.parent.branch` now names the default branch of an empty
repository when the server reports it.

## A `CommitRequest` reports each phase as the worker reaches it

A request's progress and outcome are written as soon as its branch worker reports them, instead of
Expand Down
12 changes: 9 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,8 @@ it. Every field but `spec.allowedGitTargets` is immutable:
- `spec.parentBranch`: optional branch the write branch is created from and compared against while
it does not exist. Omitted means the remote's default branch. An explicit parent must exist when
creating the write branch (`ParentBranchNotFound`). The grants need not allow it, because it is
only read.
only read. The exception is an empty repository with the parent omitted: its default branch is
created first, which is a write, so the grants must allow it (`DefaultBranchCreationNotAllowed`).
- `spec.allowedGitTargets`: which namespaces' `GitTarget`s may write through it. Omitted, its own
namespace's; a written list replaces that default, and `[]` admits none.
- `status.repository`: the normalized repository URL, half of the key one `GitBranch` holds.
Expand Down Expand Up @@ -1611,8 +1612,13 @@ parent like a moved branch: fetch, reset, replay, check again. A branch somebody
meantime is a moved remote too, so the replay builds on their commit instead of overwriting it; that
holds when the server refuses our upload too, because the fallback reads both refs from one
advertisement. A cycle planned on an empty repository that has since gained refs is refused at the
advertisement (`RepositoryNotEmptyError`) and replayed, never pushed as an orphan. A parent that is
missing, or a default branch that does not resolve, opens parent recovery on the worker: it
advertisement (`RepositoryNotEmptyError`) and replayed, never pushed as an orphan. In an **empty
repository** whose server names its unborn default branch (protocol v2 `ls-refs`, read by
`unbornHead`), a write branch that is not that branch starts from it: the worker pushes the default
branch first, alone, with one empty commit, and from then on the write branch is an ordinary absent
branch with a found parent. Over v0 the server names nothing and the write branch is the first
branch. A parent that is missing, a default branch that does not resolve, or an empty repository's
default branch the grants do not let the `GitBranch` create, opens parent recovery on the worker: it
probes on its own backoff and publishes what it held back once the parent exists (see
`docs/design/gittarget-parent-branch.md`). Proved end to end by the e2e spec "New write branch
starts from its parent"; that publication alone finds a moved parent is proved by worker tests
Expand Down
26 changes: 24 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,11 @@ It also applies the `GitProvider` and `KubeProvider` above, with their grants, a
`main` in `platform` that the platform keeps for itself (`from: Same` in `allowedGitBranches`). The
Role also needs whatever team A's own workloads require.

Start from a repository that has a first commit on `main`. A tenant may write only
`teams/<namespace>/*`, so in an empty repository it cannot create the default branch its branch
starts from, and its `GitBranch` reports `DefaultBranchCreationNotAllowed` until `main` exists
([starting the write branch](#starting-the-write-branch-from-another-branch-specparentbranch)).

| Identity | Manages | Cannot |
|---|---|---|
| The platform's reconciler | `GitProvider`, `KubeProvider` and their grants; Namespaces and their labels; tenant RBAC; platform `GitBranch`es such as `main` | |
Expand Down Expand Up @@ -1243,6 +1248,7 @@ at, and `Ready`;
| `PushSessionFailed` | The credential cannot open a push session; a holder keeps the branch meanwhile |
| `BranchConflict` | Another `GitBranch` holds this repository branch (`Stalled=True`) |
| `ParentBranchNotFound` | Neither the branch nor its parent is on the remote (`Stalled=True`) |
| `DefaultBranchCreationNotAllowed` | The repository is empty; the branch would start from the default branch, which `spec.allowedGitBranches` does not let this `GitBranch` create (`Stalled=True`) |
| `RecoveringParentBranch` | The parent is back and the worker is publishing what it held |

### Deleting a `GitBranch`
Expand Down Expand Up @@ -1551,7 +1557,20 @@ spec:
until those are published the `GitBranch` reports `Ready=False` with reason
`RecoveringParentBranch`.
- An omitted parent is the remote's default branch, as last discovered. In an empty repository (no
refs at all, tags included) the first commit starts a branch with no history. A repository that
refs at all, tags included) whose default branch is `spec.branch`, the first commit starts it with
no history. When `spec.branch` is another branch, the default branch is created first, with one
empty commit authored and signed like every other commit, and `spec.branch` starts from it: the
history starts on the default branch, which stays the default, and the first pull request into it
carries everything written. The default branch is created as soon as the worker picks up work for
the branch, even work that changes nothing, so a misconfigured credential or signing key shows up
at setup; `spec.branch` still waits for a change
([the tests](../internal/git/unborn_head_test.go)). That is a write to the default branch, so the provider's
`allowedGitBranches` must allow it for this namespace (`branchPatterns: ["main", "reverser/edits"]`).
Without that the `GitBranch` reports `DefaultBranchCreationNotAllowed`, its `GitTarget`s report
`GitBranchNotReady`, and nothing is written until the branch is allowed or someone pushes a first
commit to it. This needs the server to name its default branch, which Git protocol v2 does
(GitHub, Gitea, Forgejo); over v0 `spec.branch` is created as the repository's first branch.
A repository that
is not empty but whose `HEAD` names no branch it carries also reports `ParentBranchNotFound`, and
writes nothing until `HEAD` is fixed or `parentBranch` is set; that never starts an orphan.
- A detached `HEAD`, or a server that does not say what `HEAD` points at, resolves the way go-git
Expand Down Expand Up @@ -1714,7 +1733,10 @@ status:
created from, observed together with the branch's absence. `state` is `Found` (the first commit
builds on `commit`, the parent's tip), `Missing` (the parent is not on the remote either; the
`GitBranch` reports `ParentBranchNotFound` and nothing is written) or `Unborn` (an empty repository, with
no refs at all, and no parent configured; the first commit starts the branch with no history). See
no refs at all, and no parent configured). For `Unborn`, `branch` is the branch the empty
repository's `HEAD` names, which only a server speaking Git protocol v2 reports, and empty when the
server does not say. Unless it is `spec.branch`, it is created first and `spec.branch` starts from
it. See
[starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch).

`kubectl get gitbranch` shows `commit` in the `Commit` column, and `-o wide` shows
Expand Down
Loading
Loading