# Repository mirrors A **pull mirror** is a read-only repository that keeps its branches and tags equal to an upstream's: a copy of `https://github.com/go-git/go-git` that builds and browsing can use, and that survives the upstream going away. Push mirrors are not built yet; see the spec in `.scratch/2026-10-06-repo-mirrors/spec.md`. ## Creating one A mirror starts life as an [import](./repo-import.md) with **Keep this repository in sync** ticked, or `"mirror": true` on `POST /api/imports`. - The interval is picked from presets (10 minutes, 1 hour, 8 hours, 1 day, 1 week) that fit `mirror.min_interval`, plus `mirror.default_interval`. The API takes any Go duration from `mirror.min_interval` to 30 days. - The token is sealed with `security.secret_key` (see [Credentials](#credentials)); the username is stored as is. A token with no key configured is refused before the import starts. - `RepoService.createFromImport` writes the `repo_mirrors` row right after the repo row. If that fails, the import is abandoned, so there is never a mirror without its row. - The new mirror is indexed for code search and its dependencies parsed once. Plain imports skip this. - The import is audited as `repo.mirror.create`. An existing repository can't become a mirror. ## Syncing `MirrorService.Sync` (`internal/service/mirror_service.go`) fetches into the live bare repo: - **Refs:** `+refs/heads/*:refs/heads/*` and `+refs/tags/*:refs/tags/*`, forced, with `Prune`. The mirror follows force pushes and deleted branches and tags. GitHub's `refs/pull/*` is never fetched. - **Guard:** the fetch goes through the import's SSRF guard (`importGuard`): private-network addresses are refused unless `mirror.allow_local_networks` is set, and the pack and ref advertisement are capped as for imports. - **Packs:** go-git's client strips `thin-pack` from what it asks for, so fetched packs are self-contained and keep the storer's packfile fast path. See [git-transport: Thin packs](./git-transport.md#thin-packs). - **No branches:** an upstream that advertises no branches fails the sync before the fetch, so prune can't delete the mirror's branches. The mirror keeps its last copy. - **Default branch:** when the upstream's HEAD points elsewhere, the mirror's HEAD and `default_branch` follow. An upstream that doesn't say is handled as an import handles it. - **Side effects:** the refs that moved are diffed into push commands, and the sync runs what a push would: push webhooks for updated branches (with an empty pusher name), `OnPostReceive` (contributor stats, open PRs' head SHAs), code search re-indexing and dependency parsing. These run on their own five-minute context once refs have moved, even if the sync's time is nearly up or following HEAD fails, because the next sync's diff won't see those refs again. A sync records no activity events, sends no notifications and closes no issues from commit messages: an upstream's `fixes #12` names the upstream's issue. - **Failures:** returned as `MirrorSyncError`, with a message for the repo's admins that never carries the token: a private address, a size cap, the timeout, rejected credentials, an empty upstream, or a generic "check that the source is reachable". The full error is logged. ## Scheduling `MirrorService.Run`, started from `cmd/server/main.go` when `mirror.enabled` is set: - **Claiming:** every 30 s, or when woken, it claims due mirrors with `MirrorStore.ClaimDue`, an `UPDATE … WHERE repo_id IN (SELECT … FOR UPDATE SKIP LOCKED)`. Each claim sets a lease of `mirror.timeout` plus a minute. Two instances never claim the same mirror, and a crashed instance's lease expires. - **Concurrency:** at most `mirror.max_concurrent` syncs run at once on each instance. When a sync finishes it wakes the loop, so a mirror waiting for a slot starts at once. - **Shutdown:** `MirrorService.Shutdown` cancels the syncs in flight and releases their leases without recording a failure, so a restart or another instance syncs them at once. Refs a cancelled fetch already moved keep their new values but miss their side effects. - **Success:** sets `next_sync_at` to now plus the interval. - **Failure:** stores the message and backs off. The first failure waits one interval, and each further one doubles it, up to 24 h but never below the interval. - **Skipped:** archived and soft-deleted repos. - **Requests during a sync:** Sync now and new settings set `requested_at`. A sync claimed before that leaves the mirror due when it finishes, rather than pushing the next sync out by the interval or backoff. - **Sync now:** (`POST /api/repos/{owner}/{repo}/mirror/sync`, `CanWrite`) makes the mirror due and wakes the loop. A sync already holding the lease finishes first. It is refused (409), and hidden, while `mirror.enabled` is off or the mirror is archived, since no sync would run. Every schedule time the store sets comes from the database clock. ## Read-only `Repository.IsMirror` is derived with `EXISTS` on `repo_mirrors` in every repo query, and `Repository.ContentReadOnly()` is true for mirrors and archived repos alike. `service.CheckContentWritable` refuses with `ErrRepoMirror` on every git-content write: - HTTP and SSH pushes, which get `Repository is a mirror and is read-only.` - branch and tag create and delete - merge and auto-merge - applying suggestions - web file edits - release-created tags - changing the default branch The UI hides the matching controls. Creating a pull request into a mirror is refused with `Pull mirrors are read-only; open the pull request upstream.` Issues, discussions, the wiki and forks keep working. A fork of a mirror is an ordinary repository. The code page opens with a strip naming the source, the last sync and **Sync now**. While syncs fail it turns red, with the error, the next try and a link to the mirror settings. ## Managing The settings page's **Mirror** section (`CanManage`) shows the sync status and edits the source URL, username, token and interval. - **Token:** write-only. An empty field keeps the stored token, and **Remove the stored token** clears it. - **Rescheduling:** a new source or new credentials make the mirror due at once. A new interval counts from the last sync. - **Stop mirroring:** deletes the row and its token. The repository keeps its branches and becomes writable. It can't be turned back into a mirror. It is refused (409) while a sync holds the lease, because that sync would go on force-fetching into the now writable repo. - **Audit:** changes are audited as `repo.mirror.update` (the fields that changed and the URL, never the token) and `repo.mirror.delete`. Background syncs aren't audited. ## Credentials Tokens are sealed by `internal/secretbox`: - AES-256-GCM, under a key derived with HKDF-SHA256 from `security.secret_key`, with the info string `cloudzilla/mirror-credential`. - A version byte prefixes each value, and the purpose is bound as additional data. - A stored token is never returned by the UI or API, and never logged. Losing or changing the key makes stored tokens unreadable. Syncs then fail with "re-enter the token in the mirror settings". ## Configuration | Key | Default | Meaning | | ----------------------------- | ------- | -------------------------------------------------------------------------------- | | `mirror.enabled` | `true` | Run the sync loop and offer mirroring. When off, existing mirrors stay read-only | | `mirror.allow_local_networks` | `false` | Let mirrors reach private-network sources | | `mirror.min_interval` | `10m` | Shortest sync interval | | `mirror.default_interval` | `8h` | Interval for new mirrors | | `mirror.max_concurrent` | `3` | Syncs at once on each instance | | `mirror.timeout` | `30m` | Time limit for one sync | | `security.secret_key` | `""` | Seals stored tokens; at least 32 bytes | See [configuration](./configuration.md) for the env vars.