Forks and the contribution flow

A fork is how somebody with no push credential contributes at all. Here it is a new repository in your namespace that shares the upstream’s stored objects until the two histories diverge: creating one takes milliseconds and occupies nothing until you push something upstream does not have. A contribution is then an ordinary change against the upstream whose commits happen to live in your fork, and it lands through the same OWNERS sufficiency, required checks and serialized land queue as a colleague’s. There is no second path.

Two things this page describes as designed and not built — bringing a fork up to date with its upstream, and the maintainer firewall — are called out as such at the end.

What a fork is

POST /v1/orgs/{org}/repos/{repo}/forks creates one. You may fork what you may read; where it goes is up to you:

curl -X POST "$STRATUM/v1/orgs/ada/repos/cantor/forks" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"org": "bob", "name": "cantor"}'

Both body fields are optional — org defaults to your personal namespace and name to the source’s name — so the common case is a button with nothing to fill in, which is what the dashboard’s Fork button is. Creating in the target namespace is gated exactly as any other repository creation is: you need repo:write there, a verified address, and a plan that admits what you are creating — a private source asks the target to hold a private repository, so forking one into an organization on the free plan is refused with the same 402 quota: sentence a private create gets. The free-tier repository cap counts forks too: a free namespace that is at its cap is answered 402 quota: and nothing is created. The sentence says what the reader can actually do about it, which is not the same thing in the two places a cap is met: an organization is told the free plan is limited to N repositories — subscribe from Billing to create more, and a personal namespace — which is always free, has no billing page, and cannot be subscribed — is told a personal namespace holds up to N repositories — create an organization to hold more. Being sent to Billing for a namespace that has none is a dead end, and it read as the product being broken rather than as a limit. Pressing Fork on something you already forked still hands that fork back at the cap, because nothing is being made. Forking is something a signed-in person does; a service token is answered 401.

The answer is 202, not 201, and the difference is honest rather than pedantic. The repository row exists the moment the call returns; the storage pointers that make it readable are written by a job a moment later. Claiming Created would let you clone immediately and get an empty repository with no explanation. Instead the repository view carries fork_state: pending, then ready — or failed, because a fork that says it is broken beats one that silently serves nothing. fork_parent is the owner/name it came from, and the dashboard prints it under the repository name as “Forked from …”, the way every contributor expects. It is named to anyone who may read the upstream — public or not — and is null to a reader who may not, so a private repository is never named to somebody who could not otherwise know it exists.

Forking a repository you have already forked into the same place answers 200 with the fork you have, not a second copy and not an error: pressing Fork on a project you forked last month takes you to your fork, as it does on GitHub, and the dashboard says that is what happened. The name being taken by a repository of yours that is not a fork of this one is a real collision, answered 409 with the repository that is in the way and the way past it — bob/widget already exists and is not a fork of ada/widget; fork it under another name.

The fork has its own name, its own permissions, its own push path over HTTPS and SSH and its own audit trail. Your pushes are new data in a repository you own. Nothing about it is a view onto upstream that upstream can change out from under you: the storage a fork refers to is pinned for as long as the fork refers to it.

Visibility is not negotiable across a fork

A fork inherits its source’s visibility. A fork of a private repository is private, and it cannot be made public while its root is private — the request is refused with 403 a fork of a private repository cannot be made public, not warned about. A fork shares the upstream’s bytes without ever having copied them, so publishing it would publish the repository it came from to anonymous readers. The check is against the fork root, not the immediate parent, so it holds down a chain of forks. Making a fork private is always allowed.

Counting forks

GET /v1/orgs/{org}/repos/{repo}/forks lists the repositories forked directly from this one, and count is the number you may see — always the length of the list, never a stored total. A public repository’s forks can be made private afterwards, and publishing the stored number would tell every visitor exactly how many private forks exist. The dashboard’s fork count reads the same way.

Deleting an upstream does not delete its forks

You may delete a repository that has forks, as on GitHub; what differs is that the forks survive it. Every fork still reading the deleted repository’s data is promoted onto storage of its own first, and only when the last reference is released does the upstream’s storage become sweepable. A promoted fork’s fork_parent is null — the same answer as “not a fork” and as “an upstream you may not see”, deliberately, so that the field never leaks the existence of a repository you cannot read.

The contribution flow

The flow is the one every open-source contributor already knows:

  1. Fork the repository you want to change.
  2. Push to your fork — a clone and a push, over HTTPS or SSH, like any other repository.
  3. Open a change against upstream, naming your fork as its source.

Step two is an ordinary push to a repository you own. There is no special case in push authorization for forks, no borrowed permission on upstream and no staging area with its own rules: you have write access to your fork, which is why the push works; you do not have write access to upstream, which is why a direct push there is refused. Forking adds no case to push authorization at all: to the upstream, a forker is a reader, and what a reader meets on both transports is what the forker meets. A reader reads — your own token or SSH key clones and fetches the upstream, which is how you bring your fork up to date — and a reader’s push is refused with the reason, over HTTPS and SSH alike: you can read ada/cantor but not push to it; fork it and open a change from your fork, or ask an owner for write access. It used to be refused as repository not found, the answer meant for a repository you cannot read, and a contributor who had cloned it a minute earlier was left checking the URL for a typo.

Step three is POST /v1/orgs/{org}/repos/{repo}/changes with source:

curl -X POST "$STRATUM/v1/orgs/ada/repos/cantor/changes" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"from": "fix-empty-config", "target": "main", "source": "bob/cantor"}'

With source set you need only repo:read on the target — which on a public repository every signed-in person and every personal token holds, whatever namespace it was minted in. The change is yours: it is recorded with you as its author, the review’s association badge calls you first-time until something of yours has landed there and contributor after, and you take part in the review as yourself — comment, tick off the files you have read, revise, and withdraw it (POST …/abandon) if you change your mind, which otherwise takes write access. Without source you still need repo:write, because commits that are already in the target could only have got there through an authorised push; a reader who tries is answered 403 with a sentence that names forking as the way through. source must be owner/name — a bare name is ambiguous between “a repository in this org” and “a namespace” — it must be readable by you, so a private fork is masked here exactly as it is everywhere else, and it must actually be a fork of the repository being targeted. That last check is not tidiness: landing copies objects out of the source into the target, so an arbitrary repository named here would be a request to move somebody else’s bytes into a project they do not own.

The dashboard’s Changes tab offers the same thing as Propose a change to anyone who can read the repository but not push to it, with a field for the fork; readers with write access see Start a review instead. The server says which you are (viewer_write on the repository view), so the form is never offered to somebody who will be refused after filling it in.

The commits arrive when the change opens

Your commits are copied into the upstream the moment the change is registered, onto refs/staged/<change-key> — outside refs/heads/, so it is not a branch, does not appear in the branch list and no protection rule applies to it. GitHub does the same with refs/pull/N/head, for the same reason: the diff, OWNERS resolution and the land verdict then all read one repository rather than two. Deferring the copy to landing was tried first and failed exactly where you would expect — approval resolves OWNERS at a commit that was still in the fork.

A second patchset moves only what is new; re-registering the same tip moves nothing. The staged ref stays after the change is abandoned, so the objects of a change that was approved and then ejected are not swept while you are still working on it.

Everything downstream is identical to internal work: patchsets, OWNERS sufficiency, required checks and the land queue. The Land button on a change from a fork is the queue, which is how a protection rule and an approval requirement mean the same thing on a contribution from a stranger as on one from a colleague. A change’s source is reported on the wire as owner/name, and stays null for a change that did not come from a fork — and for a landed change whose fork has since been deleted, because a landing that happened is still a real thing that happened.

Workflows from a fork are held until a maintainer says so

A change whose commits come from a fork does not run its workflows. The workflow file was written by the contributor, and running it would hand a stranger a repo:read token and a machine — a fork’s change is somebody else’s code on your hardware. Its runs are recorded as blocked with blocked_reason: fork, its check rows stay queued rather than failing because nothing is wrong with the commit, and a maintainer who could land the change releases them with POST …/changes/{change}/workflows/approve. Approval is per tip: a new patchset from the fork is blocked again. In a changeset one unapproved fork member holds every member’s composed run, because a composed job checks out all of them. The full rules, including the organisation-level block that no maintainer can approve away, are in Workflows.

What a fork does not get to skip

Sharing upstream’s objects buys speed, not trust. Objects pushed to a fork go through the same quarantine, the same fetch and hash-verification of thin bases, and the same connectivity and fast-forward walk as any other push; a fork does not skip verification on the grounds that the bases “already exist”. Copying a change’s commits into the upstream materialises both repositories and moves the objects with git itself rather than by arithmetic on packs — correct and expensive over clever and conditionally wrong, when being wrong means a repository that does not fsck. Every clone of a fork, and of an upstream a fork has landed into, passes git fsck --full --strict; the serving gates check it on every change to this path.

What is not built

Syncing a fork from its upstream. There is no request that brings a fork up to date with the repository it came from. Today that is a git fetch from upstream — your own credential reads it — and a push to your fork, in your own clone, which is also where the decision a divergent history forces, merge or rebase, is yours to make. When it is built it will be fast-forward only, refusing a diverged fork in the land queue’s house style; a one-click button that silently picked one would sometimes rewrite your work.

Following the upstream’s storage. A fork keeps referring to the upstream’s storage as it was when the fork was made, and that is deliberate: the upstream’s data is pinned for the fork, and the fork never has to learn about a compaction it did not ask for.

The maintainer firewall — staged intake in front of review — is designed and published as a design. A change from a fork reaches review the moment it is registered.