- Feature Name: `git-mirror-out` (a repository admin mirrors a public
repository's trunk to GitHub or any Git host from its settings, pushed
after each change, with a deploy key the forge makes and keeps)
- Start Date: 2026-10-15
- Status: Draft. Four decisions settled by the owner on 2026-10-15 (see
[Decisions](#decisions)). Not built.
- Depends on: zit's `git export --autopush` (zit#220: export new
check-ins into a Git repository and push trunk fast-forward only);
RFC 0008 (the `a` letter); RFC 0021 (agents hold `a` under their
operator's ceiling); the sealed-secret store (internal/oltp/secret.go)
- Replaces: the operator script `/usr/local/sbin/mirror-to-github` on
the forge host and its ten-minute timer (pikchr-studio today)
- Ticket: #292. The inbound direction (a Git upstream into a quarry
store, zit `import-git --into`) is #358 and is not this RFC.
## Summary
[summary]: #summary
A repository's settings gain a **Mirror to Git** section. A repository
admin gives a remote URL (an SSH URL such as
`[email protected]:owner/repo.git`) and a branch name (default `main`). On
enable the forge makes an ed25519 key pair for that repository, keeps
the private half sealed, and shows the public half to add to the host as
a deploy key with write access. After every change to trunk the forge
runs `zit git export` with that key, which exports the new check-ins and
pushes fast-forward only. The section shows the last pushed check-in,
when, and the last error in words. Turning the mirror off stops pushing
and discards the key. Only public repositories may be mirrored.
## Decisions
[decisions]: #decisions
Settled by the owner on 2026-10-15.
1. **Outbound only, now.** This RFC pushes quarry repositories out.
Following a Git upstream in (#358) is separate work.
2. **Any repository admin may set it up.** The bar is `a`, the same as
webhooks and environments, so an agent holding `a` (curator on quarry)
may too.
3. **Public repositories only.** A private repository cannot be
mirrored, and a repository that becomes private stops pushing (see
[Private](#private)).
4. **Author email is zit's rule.** A commit carries the author's contact
email from the repository's user table, else `[email protected]`. No
new account setting; a person who wants the host to link their
commits adds a contact email.
## The setting
[setting]: #the-setting
One row per repository: remote URL, push branch (default `main`),
enabled, the sealed private key, the public key, the last exported
check-in, when it was pushed, the last error and when. The remote URL is
validated as an SSH remote with no password in it (zit refuses a
password in an autopush URL, and an HTTPS URL would need a credential
this design does not keep). Every change is written to the repository's
settings log with its actor and via, as the other settings are.
The key is made on first enable with crypto/ed25519, the private half
sealed with the same sealer that holds sync secrets, and shown only as
its OpenSSH public line. It is never shown again in private form, never
logged, and never leaves the forge except as the file the push reads for
the length of one run (written 0600 in a per-run temporary directory and
removed after). Disabling the mirror deletes the key; enabling again
makes a new one, which the admin adds to the host again.
## When it pushes
[when]: #when
After trunk moves, not on a timer. The trigger is the store's own fold
noticing a new trunk tip, which covers a direct push, an accepted
landing and a merge the forge writes, with one path. A run is queued per
repository and coalesced: several trunk moves while a run is going make
one more run, not several. Only the node holding the repository's write
lease runs it, so a two-node forge never pushes twice.
A run is `zit git export MIRROR --autopush URL --push-branch BRANCH`
against a Git repository the forge keeps under the repository's data
directory, with `GIT_SSH_COMMAND` naming the per-run key file and
`IdentitiesOnly=yes`. zit exports what is new and pushes fast-forward
only.
A failure is recorded and retried with backoff (one minute, doubling to
an hour); it never blocks the forge or the repository. The last error is
put in words for the page: "the host refused the deploy key: check it is
added with write access", "the remote's main has history this
repository does not: it was not pushed", "the host could not be
reached".
## No force
[no-force]: #no-force
The forge never force-pushes. If the remote branch has history the
export did not write (a first enable onto a non-empty repository, or
someone pushed to the mirror directly), the run refuses and says so,
and the mirror stays enabled and keeps refusing until the remote is
reset by hand. There is no "replace the remote" control in this RFC.
## Private
[private]: #private
Enabling is refused on a private repository. A mirrored repository that
is made private stops pushing at once: the next run is not started, the
status says "the repository is private, so the mirror is paused", and
the key is kept so that making it public again resumes. Nothing already
pushed is withdrawn, and the page says that too.
## What the page shows
[page]: #page
The section shows the remote, the branch, on or off, the public key
with a copy control, and the status line: last pushed check-in (linked)
and when, or the last error and when. The repository's About shows "Mirrored to <host
path>" with a link while a mirror is on and current. No help-text
paragraphs; the error sentences carry what a person needs.
## Fossil remote
[fossil-remote]: #fossil-remote
A repository can also be pushed to a Fossil server, such as
`https://fossil.craftdesign.group/quarry`, over Fossil's sync protocol:
`repo.fossilmirror.get`, `.set` and `.off`, and a "Mirror to Fossil"
section on the settings page, beside the Git one. The row (`fossil_mirror`)
exists only while the mirror is on.
- **What is pushed.** Everything Fossil sync carries: every branch, wiki,
tickets, forum and technotes, not only trunk. So a run never skips
because trunk's tip is unchanged: it runs after every fold. The retry
tick runs only a mirror that has never succeeded, or whose last attempt
failed and whose backoff (a minute, doubling to an hour) has passed.
`pushed` is trunk's tip at the last success, for display.
- **Everything in the store goes.** A Fossil push carries every
artifact the store holds, including the earlier versions of wiki pages
and documents that were redacted or moderated, and forum posts that
were hidden or removed: things the forge would not show a visitor. The
mirror's remote therefore sees more than the forge's pages do. Mirror
only to a server whose readers may see all of it.
- **No force.** Fossil sync only adds artifacts and never discards one,
so "never force" holds by construction; there is nothing to refuse
and no divergence to report.
- **The credential.** The remote is `https` (plain `http` only to this
machine, the forge's one outbound rule) with no user name, password,
query or fragment in it, and a host that is not loopback, private or
link-local unless the forge was started with `--allow-private-bridges`;
checked again at each push. The login's password is sealed with the
same seal as the deploy key, is read only by the push, and is given to
`zit push` as `ZIT_PASSWORD` in its environment, never in its
arguments. The push uses `--once`, so zit keeps no login and no remote
in the store: a stored login would be sent to any other remote the
store talks to, the inbound mirror's among them. Neither op nor page
returns the password, and the settings log records only that it
changed. It is required when the mirror is first set and when the
remote or the user changes; a blank one otherwise keeps the stored one.
Turning the mirror off deletes it.
- **The project code.** Fossil sync refuses a server whose project code
differs, and zit reports that as a refused login, so the remote must be
a clone or sibling of the repository (`fossil clone` or `zit clone` of
it). The failure is worded for the admin: wrong user or password, a
login that may not push, a differing project code, a host that cannot be
reached, a timeout.
- **Where the push goes.** The forge checks the remote's address when
the mirror is set. At each push it resolves the host once, applies the
same address rule to every answer (loopback, private, link-local and
shared ranges are refused unless `--allow-private-bridges`), and hands
zit the first vetted address as `--resolve HOST:PORT:ADDR`, so zit
never looks the name up and an answer that changes cannot matter. If
zit cannot connect to that address the next vetted one is tried, up to
three. zit 0.14.5 and later refuse to follow a redirect to another
host or port, so a server cannot send the push, and the password with
it, somewhere the forge did not check; that failure is worded for the
admin without naming the target. A host that is itself an address
needs no pin. The certificate probe that tells an untrusted certificate
from an unreachable host connects to the same vetted address.
- **A changed mirror ends a running push.** The login is bound to one
configuration of the mirror: a change to the remote, the user or the
password while a push runs ends that push without sending the new
password to the old host or recording its result.
- **Pause and refusal.** A private repository cannot be mirrored and a
mirrored one made private, or taken down, is paused exactly as the Git
mirror is; nothing already pushed is withdrawn. A repository is never
mirrored out to the Fossil it follows.
## Build order
[build]: #build-order
| Rung | Work |
|---|---|
| 1 | The setting row, key making and sealing, the settings section (enable, disable, URL, branch, public key), the private-repository refusal, the settings-log rows |
| 2 | The run: trigger on a trunk-tip change, per-repository coalescing, lease holder only, the per-run key file, `zit git export --autopush`, status and error words, backoff |
| 3 | Pause on private, the About line |
| 4 | Move pikchr-studio to the setting and retire `mirror-to-github`, its timer and the `gitmirror` user on the host |
## Testing
[testing]: #testing
- Enable on a public repository makes a key, stores it sealed, shows the
public line, and logs the change; enable on a private one is refused;
disable deletes the key; a remote URL with a password or an HTTPS URL
is refused with the reason.
- A run against a local bare Git repository as the remote (an `ssh`
stub that execs `git-receive-pack` locally) exports and pushes after a
trunk move, and a second move while it runs coalesces into one more
run.
- A remote with unrelated history is refused and recorded, and nothing
is forced.
- The key file exists only during the run and is 0600; no log line
carries it.
- A repository made private pauses; made public again resumes.
- An agent holding `a` may enable it; a person without `a` may not.