Who this is for: whoever set Revetly on a repository it cannot reach — a
source that stays stale, a revetly doctor line saying a remote could not be
reached, or a corpus that stopped changing. You need: a token from your
forge and the ability to edit this instance's .env and revetly.toml; nothing
here bills anything. Not here: what the engine may do to each repository
once it can reach it — three tiers, one push target — which is
engine-config-surface.md under [git], and what a
stale corpus means for what you can trust
(failure-and-staleness.md).
Revetly reads its sources out of git. Where those repositories are public, or sit on the same disk as the engine, nothing on this page applies — the engine fetches them, reads them, and you never declare a credential.
Where one is private, the engine needs a token. This page is what to give it,
where to put it, and what revetly doctor says at each stage.
Which git hosts. Any that accepts a token over HTTPS, which is all of them: GitHub, GitLab, Bitbucket, Azure DevOps Repos, Gitea, Forgejo, a plain git server of your own. Nothing here is written against one provider's API — the engine speaks git and hands the token to git, so a host git can clone from is a host this can authenticate to.
SSH remotes are not authenticated by the engine. A git@host:org/repo.git
or ssh:// remote needs a private key, an agent and a known-hosts policy, and
the engine's container has none of the three. Where a credential covers an SSH
remote the engine says so by name rather than trying — because presenting
nothing gets Permission denied (publickey) back, which looks exactly like a
token that was refused. Point the remote at its HTTPS URL, or leave that
repository to whatever key is already on the machine and declare nothing.
The engine touches a remote in four places: a fetch before every read of a
repo-backed source, a git ls-remote that watch mode uses to ask cheaply
whether anything moved, a clone of the records repository where one is
declared, and a push to that same records repository and to nothing else.
A repository with no remote, or one whose remote answers without asking who you
are, needs no declaration and gets no warning about one. So does an instance
that has declared [git] fetch = false — that is the local-only opt-out, and it
means every root is read from the disk it is on, no remote contacted.
Two halves, and the split is the whole rule: revetly.toml says where the
token is, the environment holds what it is. revetly.toml is committed to the
repository it configures. A token in it is a token in your history.
[git.credentials.default]
match = "https://git.example.com/product/"
# in this instance's .env, which is git-ignored
REVETLY_GIT_TOKEN=...
That is the whole declaration for an instance with one credential: token_env
is optional and defaults to REVETLY_GIT_TOKEN. The name is deliberately
about nothing — not your forge, not your organisation, not the repository —
because where your code happens to be hosted is not a property of the token.
match names the host because that is what a remote is.
Declaring several means naming a variable for each, since the point of several is that one host's token is never presented to another:
[git.credentials.product]
match = "https://gitlab.example.com/product/"
token_env = "REVETLY_GIT_TOKEN_PRODUCT"
[git.credentials.vendor]
match = "https://github.com/vendor/"
token_env = "REVETLY_GIT_TOKEN_VENDOR"
Leaving token_env off more than one is refused at load: they would all read
the same variable, and every remote would be handed the same token.
match is a URL prefix, not a path. A credential is a property of the
remote: the engine clones the records repository before any local checkout of it
exists, so a declaration keyed on a directory could not supply the one call that
most needs it. Longest match wins, so a credential for one repository can sit
beside a credential for the whole organisation.
| key | what it is | default |
|---|---|---|
match |
URL prefix this credential is for | required |
token_env |
the name of the environment variable holding the token | REVETLY_GIT_TOKEN |
username |
the username half of an HTTPS token | x-access-token |
write |
whether this credential may be used for a push | false |
A token = "..." here is refused by name, with the fix, the same way a
connector's settings table refuses one.
The engine fetches many repositories and pushes to exactly one — the records
clone, and [git] records_clone is the only key that makes a repository that.
On an instance declaring several projects there is still one: a
records_clone declared for the instance resolves against the instance, and
each project writes its records into its own folder of that clone. write
defaults to false, so an instance that declares tokens to read three
repositories can push to none of them.
A push resolves against the credentials that declare write = true and no
others. Where a read-only credential covers the push URL, the push is refused
and says so, rather than quietly using the token that happened to be there.
[git.credentials.sources] # everything it reads
match = "https://git.example.com/product/"
token_env = "REVETLY_GIT_TOKEN_SOURCES"
[git.credentials.records] # the one it writes to
match = "https://git.example.com/product/revetly-records.git"
token_env = "REVETLY_GIT_TOKEN_RECORDS"
write = true
Scope the tokens the same way at the forge. A read credential wants contents read access and nothing else.
A fine-grained GitHub token carries one permission set across every repository
it selects, so a token that may push to the records repository may also push to
everything else it can see. write = false stops this engine from trying, and it
cannot un-grant what the forge granted — which is why the two credentials above
name two variables and hold two differently-scoped tokens.
Most hosts ignore it entirely when the password is a token, which is why it
defaults to x-access-token and most instances never set it. Two that do read
it:
| host | username |
|---|---|
| GitHub, GitLab, Azure DevOps, Gitea | anything — leave it |
| Bitbucket Cloud, API token | x-token-auth |
| Bitbucket Cloud, app password | your account name |
The engine runs as a container with no interactive shell, no keychain, no ssh-agent and no credential helper of its own. You do not need one. The credential arrives the same way the LLM key does:
/project/.env — the instance's own file, git-ignored.[git.credentials.<name>] table in revetly.toml.docker compose exec revetly revetly doctorNothing in docker-compose.yml changes. The engine loads this instance's .env
before every check and every pass, so a token added there is live on the next
command.
The token is never written into a remote URL. It is handed to git through a credential helper built for one invocation and read from the environment, which is why it does not appear in the error text git produces when a fetch fails — and that text is quoted into the pass report, the freshness row, the doctor chain and a connector's stale reason. Every failing git message is put through the redactor as well, so a URL you wrote by hand with a token in it is blanked on the way out.
[PASS] git credentials default: https://git.example.com/product/ (read only), REVETLY_GIT_TOKEN is set
[PASS] git remote: product origin/main — credential default accepted
[PASS] git push: records origin/main — credential records may push (nothing transferred, no ref moved)
Four states for the read, and the fix differs for each:
| line | what it means | what to do |
|---|---|---|
no credential declared for it, so it is reached as a public remote |
nothing needed, or nothing declared yet | if the fetch works, nothing |
REVETLY_GIT_TOKEN is not set |
the declaration is there, the value is not | export it — it never goes in revetly.toml |
credential default was rejected (…) |
the remote refused the value, and the brackets carry what it said | issue a new token; this one will be refused identically next pass |
could not be reached (…); the credential itself was never judged |
weather | try again |
And what each of them weighs. For every credential except the one that
declares write, the last three are a WARN and never a FAIL.
git ls-remote is how this engine decides whether a clone is behind its remote;
a credential that cannot make that call costs the freshness claim and not the
read. The connector reads the revision already on disk, reports that view as
incomplete, tombstones nothing, and the pass completes — which is what happened
here on 2026-08-25, when a fine-grained token had not selected a newly declared
repository and the pass triggered by a set_status finished normally with every
artifact from that repository extracted. Doctor called it a failure and told the
operator a pass would spend and fail partway; the decision that produced was to
not run a pass for two hours. A diagnostic that stops correct work is worse than
one that says nothing, because it is trusted.
The credential that declares write is the exception, and it is a FAIL. That is
the one this engine pushes with, and a value the remote refuses there means an
approved draft is committed and reaches no remote — exactly the failure this
severity split exists to prevent. Nothing new declares the difference: write
already did.
Both severities are the same for a missing credential as for a rejected one on the same repository. Which of the two it is changes the fix, and the fix is what the lines above differ in; it does not change the consequence, so it does not change the weight.
The first two cost nothing and need no network, so they answer under
--offline. The third cannot be known without asking, so it is asked with one
git ls-remote and skipped when offline.
git ls-remote is a read. All four states above are statements about reading,
and a token that fetches a remote all day may still be refused
git-receive-pack — which is exactly how an approved draft gets committed
locally, fails to reach the remote, and leaves a chain that says
credential records accepted.
So a credential that declares write = true is asked with the operation it
declares: one git push --dry-run against the records remote. It negotiates,
transfers no objects and updates no ref — on a remote already up to date it says
Everything up-to-date and stops there.
git push: … line |
what it means | what to do |
|---|---|---|
credential records may push |
the write half holds | nothing |
accepted for reading and refused for writing |
the token reads this repository and may not write it | grant it write access to that repository's contents, or point token_env at one that has it — the token is not wrong, this is the one permission it lacks |
the write half is undetermined |
the forge answered in a way that cannot be told from a refusal | nothing to rotate on the strength of it; the push itself reports what happens |
could not be reached |
weather | try again |
no credential declares write for the one remote it writes to |
a read-only credential covers the records remote | declare write = true on a credential matching it |
Scope, deliberately. It runs for the records remote alone and for credentials
that declare write = true: a read-only credential refused a push on a doc-root
remote is correct behaviour, and reporting it would put false alarms on a chain
people would then learn to skip. It is a network call, so it is skipped under
--offline like the rejection probe beside it. It is a WARN and never a FAIL —
ingest, capture and review all still work, the commit is durable locally, and git
pushes everything it is behind by as soon as the token is widened. What is lost
is not the write; it is knowing.
Nothing about the check writes: no ref moves, no credential is minted or widened, and no remote configuration is changed.
An authentication that was refused will be refused identically on the next tick and the one after. A network that was down may not be. So they are told apart and reported apart: an unreachable remote makes watch mode fall back to a full read, which is the right price for not knowing, and a rejected credential does not — it names the fix, leaves the local fingerprint to decide whether anything moved, and stops there.
An engine that cannot reach a remote reads the revision already on disk and reports that view as incomplete. That is deliberate and it is the safety property: absences in an incomplete read are not withdrawals, so nothing is tombstoned on the strength of a repository the engine could not see.
The cost is that a frozen corpus is quiet. It reports as one warning among
warnings, and it stays that way until somebody reads it. If revetly status
shows a source stale for days, this page is where to look first.