| 1 | # Mirroring: keeping remotes in sync |
| 2 | |
| 3 | > **2026-10-08:** "bidirectional synchronization from GitHub or whatever |
| 4 | > providers … as well as setting up a different remote (think self-hosted g1t |
| 5 | > and mirroring to the hosted g1t.sh platform)". "GitHub is down, I want to |
| 6 | > run GitHub's workflows on g1t, but only until GitHub is back up. But what if |
| 7 | > I push on g1t?" "It needs to be highly clear when a repository is mirrored, |
| 8 | > and things are disabled." "When it's mirroring it's just mirroring … you |
| 9 | > can use g1t as a disaster recovery option intentionally." "We really don't |
| 10 | > want to force someone's hand." "When we've decided that we've moved the |
| 11 | > repository entirely to g1t and it's no longer a mirror … mention that we'll |
| 12 | > no longer track the remote." |
| 13 | |
| 14 | The user-facing guide is `apps/docs/src/content/docs/guides/mirroring.md`. |
| 15 | This is the design record. |
| 16 | |
| 17 | ## The rule |
| 18 | |
| 19 | **Every linked repository has exactly one leader. Work happens on the |
| 20 | leader; the others follow.** g1t is never a second place where people work |
| 21 | while the remote also leads, so there's nothing to merge back by surprise. |
| 22 | Every state is an answer to "who leads right now?": |
| 23 | |
| 24 | | State | Leader | On g1t | |
| 25 | | --- | --- | --- | |
| 26 | | `standby` | remote | Exact, read-only copy. Runs nothing (optional: keep CI warm). | |
| 27 | | `ci` | remote (code) | The remote's workflows run on g1t; results stay on g1t for now. | |
| 28 | | `takeover` | g1t, for now | Everything works. Deploying workflows wait for approval. | |
| 29 | | `handing_back` | moving back | Read-only while refs move. | |
| 30 | | moved to g1t | g1t | Not a mirror. g1t no longer tracks the remote, or keeps it as a follower. | |
| 31 | | `following` / `stuck` | g1t | A follower remote, pushed to on every `git.push`. | |
| 32 | |
| 33 | Outside a takeover, every commit on a mirror's branches is already on the |
| 34 | remote. So a standby mirror is safe to treat as a backup, and any |
| 35 | difference between the two is a bug. |
| 36 | |
| 37 | ## Decisions taken |
| 38 | |
| 39 | 1. **A mirror is a standby by default.** No issues, pull requests, agents, |
| 40 | workflows or deployments, so mirroring is just mirroring. Settings, |
| 41 | rules, webhooks and reported checks stay open. |
| 42 | 2. **Detection shows; it doesn't act.** Hosts are probed every minute. A |
| 43 | host is unreachable after 3 failures over at least 2 minutes, and back |
| 44 | after 3 successes over at least 5. By default this only turns the |
| 45 | repository's dot amber and offers **Take over**. Telling owners in the |
| 46 | inbox, and taking over automatically after N minutes, are opt-in |
| 47 | settings per link. |
| 48 | 3. **Take over any time**, not only during a detected outage. |
| 49 | 4. **Hand back is a reviewed plan**, ref by ref, against the commit each |
| 50 | ref had when the takeover began: |
| 51 | - `push`: only g1t moved. |
| 52 | - `fetch`: only the remote moved. |
| 53 | - `pull_request`: the remote protects the branch; the commits go to |
| 54 | `g1t/handback/<branch>` and g1t follows the remote's branch. |
| 55 | - `diverged`: both moved; a person decides keep ours, keep theirs or |
| 56 | pull request. |
| 57 | |
| 58 | If anything is refused, g1t keeps the lead and says why. An automatic |
| 59 | hand-back happens only for takeovers g1t started itself, and only when |
| 60 | no ref has diverged. |
| 61 | 5. **Nothing is lost silently.** When a pull would drop a commit (a |
| 62 | force-push or deletion on the remote, or a keep-theirs decision), that |
| 63 | commit is kept at `refs/g1t/replaced/<ref>/<ms>` for at least 30 days. |
| 64 | 6. **CI failover is its own switch.** It runs `.github/workflows` as well |
| 65 | as `.g1t/workflows`; g1t's wins a `name:`. |
| 66 | 7. **Move to g1t** is permanent. The confirmation says g1t will no longer |
| 67 | track the remote. Optionally the remote becomes a follower. |
| 68 | 8. **Security.** |
| 69 | - A push copied in that changes `.g1t/`, `.github/workflows/` or |
| 70 | `.github/actions/` starts runs that wait for approval before they can |
| 71 | use secrets. |
| 72 | - Agents never move a repository to g1t, nor add, change or remove a |
| 73 | remote. |
| 74 | |
| 75 | ## Where it lives |
| 76 | |
| 77 | - **`crates/contracts/src/mirrors.rs`** (and `packages/contracts/src/mirrors.ts`): |
| 78 | - the model: `MirrorState`, `RepoMirror` on `Repo`, `Remote`, |
| 79 | `MirrorSettings`; |
| 80 | - the hand-back logic: `ref_action` and `HandbackPlan`; |
| 81 | - `MirrorEvent`. |
| 82 | - **repos** (`src/mirror.rs`, migration 0018 `repos.mirror`): |
| 83 | - `set_mirror`; |
| 84 | - `mirror`, which now also keeps replaced commits and announces pushes |
| 85 | as `mirrored`; |
| 86 | - `mirror_refs`, which returns g1t's side alone when there is no URL; |
| 87 | - `mirror_apply`, which moves named refs with a compare-and-swap; |
| 88 | - `read_only_refusal` at every write path: the git front door, the |
| 89 | commit API, merges, catch-up, releases and pull-request copies. |
| 90 | - **integrations** (`src/remotes.rs`, migration 0007 `remotes`, |
| 91 | `remote_refs`, `remote_hosts`; cron every minute; `EVENTS` binding): |
| 92 | - the links; |
| 93 | - the provider adapters: `github` through the App, `g1t` and `git` with |
| 94 | a sealed token, and polling every 5 minutes for hosts without webhooks; |
| 95 | - takeover, CI, hand-back, move-in, health and the automatic levers. |
| 96 | - `github_repos` mirror and push rows became remotes in the migration; |
| 97 | the GitHub webhook's pushes go to remotes. |
| 98 | - **work**: `retired::writable` refuses on a read-only mirror; |
| 99 | `not_archived` is kept for settings, rulesets and commit checks. |
| 100 | - **actions** (`src/mirrored.rs`): what runs per state, `.github` reading, |
| 101 | holding deploys, approval for copied workflow changes. |
| 102 | - **deployments**: production deploys only while g1t leads. |
| 103 | - **events**: `mirror.*` in the inbox (only the people named in `notify`) |
| 104 | and in the webhook catalogue. |
| 105 | - **api** (`src/mirrors.rs`): |
| 106 | - REST under `/repos/{owner}/{name}/mirror…`; |
| 107 | - MCP `mirror_*` actions on the `repository` tool; |
| 108 | - scopes: `repo:read` to read, `code:write` to sync, `repo:admin` for |
| 109 | everything else. |
| 110 | - **web**: the badge, banner, disabled states, clone note and |
| 111 | **Settings → Mirroring**. |
| 112 | |
| 113 | ## Not yet |
| 114 | |
| 115 | - **Check runs posted back to GitHub** during CI failover. Needs |
| 116 | `checks: write` on the App. |
| 117 | - **Starting CI failover automatically** when GitHub starts no check suite |
| 118 | for a pushed commit. Needs `checks: read`. |
| 119 | - **Copying GitHub's branch protection** into g1t during a takeover. Today |
| 120 | a takeover runs under g1t's own rules. |
| 121 | - **Locking the remote** with a ruleset so only g1t pushes. Needs |
| 122 | `administration: write`. |
| 123 | - **GitHub's pull requests read into a mirror**, and agents opening theirs |
| 124 | there. |
| 125 | - **A working mirror** (write-through while GitHub leads). Deliberately |
| 126 | left out; it could come back as one toggle on a standby mirror. |
| 127 | - **GitLab and Bitbucket adapters**: one arm each in `remotes.rs` |
| 128 | (`credential`, `protected`, `open_pull_request`), plus a webhook route. |
| 129 | - **Taking in fast-forwards on a follower** skips g1t's branch rules on the |
| 130 | copied push. A follower's pushes are already gated by the remote's own |
| 131 | rules. |