Git Remotes
A site follows as many git repositories as you connect to it. A push installs the module, and the
result is written back to the provider as a commit status. The page that manages remotes is
/_cfw/git, and it drives three files: src/ops/git-smart.ts (the transport),
src/ops/git-provider.ts (the provider layer above it) and src/ops/git-sync.ts (the layout and
diff).
The Transport
The transport is git's own smart HTTP, so anything that serves a repository over HTTPS works: GitHub, GitLab, Bitbucket, Gitea, Forgejo, a bare repository behind nginx, or an internal mirror. Two requests do the whole job:
| Step | Request | Cost |
|---|---|---|
| Poll | GET /info/refs?service=git-upload-pack |
a few hundred bytes when nothing has moved |
| Pull | POST /git-upload-pack, one commit, depth 1 |
the changed objects, as a packfile |
The packfile is read in the Worker: pkt-line framing, both delta forms, and the tree walk that turns a commit into files. Private repositories authenticate with the operator's access token over HTTPS. A public repository needs no credential.
Provider APIs
Provider APIs sit above the transport, and the transport does not depend on them. The generic
provider uses no API at all. Its branches and open requests come from the ref advertisement, since
every provider publishes refs/pull/N/head or refs/merge-requests/N/head.
| Provider | API | Default host |
|---|---|---|
github |
REST v3 | https://api.github.com |
gitlab |
REST v4 | https://gitlab.com |
bitbucket |
REST 2.0 | https://api.bitbucket.org |
gitea |
REST v1, Forgejo too | none; the host is required |
generic |
none | none; the host is required |
What Gets Installed
Module names come from *.info.yml and not from the directory. A repository checked out under one
name that provides another installs correctly, and a repository holding several extensions installs
all of them at their own paths. Modules land in modules/custom/, themes in themes/custom/ and
profiles in profiles/custom/.
Only mountable files are kept: PHP, YAML, Twig, JavaScript and CSS. tests/, vendor/,
node_modules/, .github/ and dotfiles are dropped. Each kept file is stored as its own row, and
the pull reports what it skipped and why.
PHP costs no bundle bytes. Module source is fetched over the assets binding and not compiled into the Worker, so the size ceiling that governs the rest of the runtime does not apply to a custom module written in PHP.
A submodule inside another module's tree stays where it is and is mounted once.
Mount Path
A remote can name a mount: a path inside modules, themes or profiles, such as
modules/mantle2. Set it when a site already keeps the module somewhere else. A pull then updates
the module where it lives, instead of adding a second copy beside it. The setting applies when the
repository holds one module. With several, each keeps its default mount. /git?action=add&mount=
takes it.
A path containing .., a dot segment, a leading slash or a core/ prefix is refused, both when the
remote is added and on every pull.
vendor/ is never delivered. A module whose dependencies are bundled inside vendor/ keeps the
ones it was carried with, and a new dependency in a later commit has to arrive another way.
Branches, Requests and Diffs
| Action | What it does |
|---|---|
| Check | reads the remote head without downloading anything |
| Diff | fetches the commit and reports added, modified and removed files with line counts |
| Pull | applies that diff and mounts the result |
| Branch switch | moves the site to another branch, deleting what the old one had and the new one does not |
| Requests | lists open pull and merge requests |
| Preview | installs a request's head instead of the branch, and holds it there |
A preview is held against polling and against pushes, so a branch that moves while somebody is reviewing does not replace what they are looking at. Leaving the preview returns the site to its branch.
Triggers
Polling works against any remote and needs no cooperation from it. Each repository has its own
interval. It defaults to 60 minutes and cannot go below 5; 0 turns polling off. A poll costs one
ref advertisement. The alarm polls at most three due remotes per firing, oldest first, so one slow
remote cannot starve the others.
A 429 response sets a backoff. The first is one minute, each refusal doubles it, and a
Retry-After header replaces the calculation when the provider sends one. Both are capped at one
hour (src/ops/git-sync.ts).
Webhooks make a push arrive immediately. GitHub, GitLab, Bitbucket, Gitea and Forgejo can register one from the page. For any other remote, the page mints a secret and shows the delivery URL to register by hand.
Deliveries
/githook?remote=<id> receives deliveries. The caller carries no credential this Worker controls,
so the signature is the whole of the authentication. An unverifiable delivery is refused.
| Provider | Header | What it proves |
|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 over the raw body |
| Gitea, Forgejo | X-Hub-Signature-256 |
the same scheme, verified the same way |
| Bitbucket | X-Hub-Signature |
HMAC, method read from the header prefix |
| GitLab | webhook-signature |
HMAC-SHA256, GitLab 19.0 and a feature flag |
| GitLab | X-Gitlab-Token |
the shared secret, in plaintext |
| Any git remote | X-Hub-Signature-256 |
accepted if the sender can produce it; refused otherwise |
GitLab installs before 19.0 send the secret in plaintext and cannot be HMAC-verified. Both forms are accepted, and the page shows which one the last delivery used, since the two differ in what they prove.
What a Push Does
A push to the tracked branch installs the commit inline and writes the result back as a status. A push to any other branch and a branch delete are acknowledged without installing or recording the head. A push to the tracked branch arriving while a request is being previewed records the head and stops there.
sequenceDiagram
participant H as Git host
participant W as Worker
participant O as Site object
H->>W: POST /githook?remote=id
W->>O: forward to the site object
O->>O: verify the signature and read the event
alt signature does not match
O-->>W: 401 refused
else other branch or a delete
O-->>W: acknowledge, no install
else preview in progress
O->>O: record the head and stop
else tracked branch
O->>H: fetch the commit at depth 1
H-->>O: packfile
O->>O: plan, apply and verify the boot
O->>H: commit status on the pushed sha
end
O-->>W: JSON result
W-->>H: 200The status write runs only when the provider has an API and the remote holds a token. A plain remote gets no status.
Verify and Roll Back
A pull is applied in one transaction and then verified. Verification has two checks. The kernel boot
builds the container, and the module load runs every enabled module's PHP, because a boot alone reads
no .module file when the container comes from cache_container. A missing verdict from either
check counts as a failure.
If either check fails, the previous file set is restored and the site keeps serving. The reason is
stored on the remote as its last error, and the pull reports rolledBack. A path already owned by
another remote or by a composer require is reported as a conflict and not taken.
flowchart TD
A[Plan and conflict check pass] --> B[Write the file set in one transaction]
B --> C[Boot the Drupal kernel]
C -->|error or no verdict| R[Restore the previous file set]
C -->|booted| D[Load each enabled module file]
D -->|refused or no verdict| R
D -->|loaded| E[Record the installed sha and clear the last error]
R --> F[Record the reason on the remote]The checks are in gitVerifyBoot() (src/do/git-delivery.ts). gitApply() and gitRestore() in
the same file write and restore the set.
Write-Back
A commit status goes on the pushed sha: a state, a context, a description and a link. All three providers support it, and an access token can write it.
| Provider | Read the tree | Write a status | Create a webhook |
|---|---|---|---|
| GitHub | Contents: read |
Commit statuses: write |
Webhooks: write |
| GitLab | read_api |
api |
api, Maintainer or Owner |
| Bitbucket | repository:read |
repository:read |
webhook |
| Gitea, Forgejo | read:repository |
write:repository |
write:repository |
| Any git remote | read access over HTTPS | no API | register by hand |
Provider scope notes
GitHub's Checks API carries annotations and per-line output, and only a GitHub App can write one. A
repository-scoped fine-grained token is refused with Resource not accessible by personal access token on both check-run endpoints and accepted on the status endpoint, so the product writes commit
statuses.
GitLab has no scope narrower than api for writing a status.
Bitbucket has no personal access token. An Atlassian API token authenticates as HTTP Basic with the account email as the username, so its row asks for one.
Gitea takes state on the way in and answers status on the way out, which matters when reading a
status back through its API.
A plain remote has nowhere to put a status, so statusRequest() returns null for it.
Storage
| Setting | Where | What it does |
|---|---|---|
git_remotes |
sealed | the configured remotes, as one JSON row |
git_token_<id> |
cfw_meta |
the access token for one remote |
git_email_<id> |
cfw_meta |
the Atlassian account email, Bitbucket only |
git_hooksecret_<id> |
cfw_meta |
the shared secret a delivery is verified against |
git_head_<id> |
cfw_meta |
the sha the last poll saw |
git_installedsha_<id> |
cfw_meta |
the sha whose files are mounted |
git_interval_<id> |
cfw_meta |
poll cadence in minutes; 0 turns polling off |
git_backoff_<id> |
cfw_meta |
epoch ms before which nothing polls this remote |
git_previewof_<id> |
cfw_meta |
the request being previewed instead of the branch |
Module source lands in cfw_module_file, one row per file, owned by the remote id. That is the same
table composer require writes to, which is what makes a conflict detectable.
Everything in this table lives in the object's own storage, and none of it can be set from KV. A writer who could change a remote could point the site at a repository they control. A writer who could read a token would have push access to the operator's repository. Access tokens therefore live in the site's own database, never in the KV namespace that carries runtime overrides. The scopes each provider needs are under Write-Back.
Provider Coverage
| Provider | State | Exercised Against |
|---|---|---|
| GitHub | verified | github.com |
| GitLab | verified | GitLab CE |
| Gitea | verified | Gitea 28.0.0 |
| Forgejo | verified | Forgejo 16 |
| Any HTTPS remote | verified | git upload-pack directly |
| Bitbucket | supported | n/m |
Bitbucket authenticates with one API token and two names. The REST API takes the Atlassian account email, and git over HTTPS takes the Bitbucket username, which is case sensitive. Both fields are on the page.