Skip to content

Commit

Mirroring on the site and in the docs

A mirror says so beside its name (Mirror of, running CI, Taken over from, Handing back to, Mirrored to) with a banner when the remote isn't answering or g1t leads; every button that would write is greyed out with the reason. Settings -> Mirroring: where work happens, take over, CI failover, the hand-back plan with decisions for diverged branches, move to g1t ("g1t will no longer track the remote"), the link's levers, and followers. New guide guides/mirroring; GitHub and Actions guides updated; docs/MIRRORING.md is the design record.

syntaqxcommitted Parent80a734aBrowse files
34 files+2143−4840/34 viewed
+1−0
142142 items: [
143143 { label: 'Accounts and sign-in', slug: 'guides/authentication' },
144144 { label: 'GitHub', slug: 'guides/github' },
145+ { label: 'Mirroring', slug: 'guides/mirroring' },
145146 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
146147 { label: 'Access and roles', slug: 'guides/access-and-roles' },
147148 { label: 'Teams', slug: 'guides/teams' },
+22−2
1919 Workflows that still say `uses: ./.github/actions/setup` find it under
2020 `.g1t/` once `.github` is gone.
2121
22−g1t never reads `.github`. A repository mirrored to both places can keep
23−`.github` for GitHub and `.g1t` for g1t, side by side.
22+Otherwise g1t doesn't read `.github`, so a repository that lives in both
23+places can keep `.github` for GitHub and `.g1t` for g1t, side by side.
24+
25+### On a mirror
26+
27+A [mirror](/guides/mirroring/) of a GitHub repository runs nothing while it
28+stands by: its workflows run on GitHub. Its owners can change that:
29+
30+- **CI failover** runs its workflows on g1t for each push copied in from
31+ GitHub, `.github/workflows` as well as `.g1t/workflows`. Use it when
32+ GitHub's workflows aren't running.
33+- **Taking over** runs them for everything pushed to g1t, until it's handed
34+ back.
35+- **Keep CI warm** runs `.g1t/workflows`, and only those, on each push
36+ copied in while it stands by.
37+
38+When both folders have a workflow of the same `name:`, the one in `.g1t`
39+runs. A workflow that deploys (any job names an `environment:`) waits for
40+approval during CI failover and takeovers, so it doesn't deploy from two
41+places, unless the mirror's settings say otherwise. A push copied in from
42+GitHub that changes workflows or local actions waits for approval before
43+it can use the repository's secrets.
2444
2545 Then add your [secrets and variables](#secrets-and-variables): GitHub never
2646 gives their values out, so they cannot be copied across.
+18−14
8686 | Choice | What happens | Where you push |
8787 | --- | --- | --- |
8888 | **Import** | Copied once. The g1t repository is then its own. | g1t |
89−| **Mirror** | Copied, then every push to GitHub is fetched into g1t within seconds. Branches deleted on GitHub are deleted on g1t. | GitHub |
90−| **Move to g1t** | Copied, then every push to g1t is pushed to GitHub, so people still working there see it. Branches that exist only on GitHub are left alone. | g1t |
89+| **Standby mirror** | Copied, then every push to GitHub is copied in within seconds. On g1t it's a read-only copy that runs nothing, until you take over. | GitHub |
90+| **Move to g1t** | Copied, then g1t leads: every push to g1t is pushed to GitHub, so people still working there see it. Branches that exist only on GitHub are left alone. | g1t |
9191
92−A mirror is a copy that follows GitHub: anything pushed to the g1t copy
93−directly is overwritten at the next sync. Deployments, previews, checks
94−and agents run on the copy as on any project.
92+A standby mirror is a backup you can switch to: if GitHub is down, or
93+whenever you want to work on g1t for a while, **take over** in its
94+**Settings → Mirroring**, and **hand back** when you're done. You can also
95+run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t
96+for good. See [mirroring](/guides/mirroring/).
9597
9698 ### What comes across
9799
104106 | Issue numbers | New numbers on g1t; each issue links to the original |
105107 | Comments on issues | No |
106108 | Pull requests | No. Their branches come across; open pull requests stay on GitHub. |
107−| GitHub Actions workflows | Yes, as files. Rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). |
109+| GitHub Actions workflows | Yes, as files. A mirror runs them on g1t in CI failover and when taken over; otherwise rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). |
108110 | Releases, wikis, Git LFS objects | No |
109111
110112 A repository is copied in one piece of at most 40 MB, after compression.
112114
113115 ### Keep a mirror in step
114116
115−The repository's overview shows where it came from and when it last
116−synced. **Sync now** copies at once; **Stop mirroring** (or **Stop
117−pushing**) ends the tie and leaves the g1t repository as it is. A mirror
118−also stops, keeping its copy, when:
117+The repository's **Settings → Mirroring** shows the link: its state,
118+whether GitHub is answering, and when it last synced. **Sync now** copies
119+at once. **Take over**, **Hand back**, **Move to g1t** and **Remove** are
120+there too: see [mirroring](/guides/mirroring/). A standby mirror stops,
121+keeping its copy, when:
119122
120123 - the repository is deleted on GitHub,
121124 - the app is uninstalled from its GitHub account, or
122125 - a workspace owner removes that GitHub account from the workspace.
123126
124−If GitHub stops letting the app see a repository, its overview says so;
125−add it back to the installation on GitHub to carry on. Renaming or
127+If GitHub stops letting the app see a repository, its settings say so; add
128+it back to the installation on GitHub to carry on. Renaming or
126129 transferring a repository on GitHub keeps the mirror working.
127130
128131 ## What g1t's GitHub App can do
129132
130133 | Permission | Access | Why |
131134 | --- | --- | --- |
132−| Contents | Read and write | Read: clone the repositories you choose. Write: only for **Move to g1t**, to push. |
133−| Metadata | Read | Required by GitHub for every app: names and visibility. |
135+| Contents | Read and write | Read: clone the repositories you choose. Write: to push for **Move to g1t**, and to hand a takeover back. |
136+| Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. |
134137 | Issues | Read | Copy issues when you ask. |
138+| Pull requests | Read and write | When a takeover is handed back, open a pull request for a branch GitHub protects. Without it, g1t pushes the commits to `g1t/handback/<branch>` and tells you to open the pull request yourself. |
135139 | Email addresses (account) | Read | Find your verified email when you sign in. |
136140
137141 The app only ever sees the repositories you or your organization's owners
+218−0
1+---
2+title: Mirroring
3+description: Keep a repository in step with copies of it elsewhere. A mirror stands by as a read-only copy until you take over; hand it back when you're done, or move it to g1t for good.
4+---
5+
6+Mirroring keeps a repository on g1t in step with a copy of it on another
7+host: GitHub, another g1t (such as one you run yourself), or any git host
8+over HTTPS.
9+
10+Every linked repository has exactly one **leader**, where work happens. The
11+other copies follow it. That one rule answers every question about which
12+push wins: a write reaches the leader, or it doesn't land.
13+
14+| The repository on g1t is | Who leads | What works on g1t |
15+| --- | --- | --- |
16+| A **mirror**, standing by | The remote | Reading, cloning, search. Nothing else: it's an exact, read-only copy that runs nothing. |
17+| A mirror in **CI failover** | The remote, for code | The remote's workflows run on g1t. Code, issues and pull requests stay on the remote. |
18+| A mirror **taken over** | g1t, for now | Everything: pushes, pull requests, issues, agents, workflows. |
19+| **Mirrored to** other hosts | g1t | Everything. Each push is sent to the followers. |
20+
21+## Make a mirror
22+
23+- **From GitHub:** choose **New project → Import from GitHub**, pick the
24+ repositories and choose **Standby mirror**. See [GitHub](/guides/github/).
25+- **From another g1t or any git host:** create an empty repository, then
26+ in its **Settings → Mirroring** choose **Add a remote**, with **The remote
27+ leads**. Give its https address and a token that can read it. The
28+ repository is filled from the remote, and only an empty repository can
29+ become a mirror.
30+
31+A mirror follows every branch and tag of its remote:
32+
33+- **GitHub** tells g1t about each push, so the mirror catches up within
34+ seconds.
35+- **Other hosts** are asked every five minutes.
36+
37+Branches deleted on the remote are deleted on g1t. When the remote
38+force-pushes a branch or deletes it, the commit g1t had is kept under
39+`refs/g1t/replaced/<branch>/<time>` for at least 30 days. Nothing a mirror
40+held is lost silently.
41+
42+## What a mirror standing by refuses
43+
44+A standby mirror is a backup you can switch to. It doesn't take work of its
45+own:
46+
47+| | Standing by | CI failover | Taken over |
48+| --- | --- | --- | --- |
49+| Clone, fetch, browse, search | ✓ | ✓ | ✓ |
50+| Push, merge, edit on the web | – | – | ✓ |
51+| Issues, pull requests, agents | – | – | ✓ |
52+| `.g1t/workflows` | Only with **Keep CI warm** | ✓ | ✓ |
53+| `.github/workflows` | – | ✓ | ✓ unless turned off |
54+| Deployments on g1t.page | – | – | ✓ |
55+| Settings, rules, webhooks | ✓ | ✓ | ✓ |
56+
57+What's refused says why. A `git push` to a standby mirror answers:
58+
59+```text
60+remote: acme/web is a mirror of github.com/acme/web, so it is read-only here.
61+remote: Push to github.com/acme/web, or take over in Settings → Mirroring to work on g1t.
62+ ! [remote rejected] main -> main
63+```
64+
65+The repository shows **Mirror of github.com/acme/web** beside its name,
66+and every button that would write is greyed out, with the reason on hover.
67+
68+## When the remote stops answering
69+
70+g1t checks each remote's host every minute. A host is **unreachable**
71+after three failed checks over at least two minutes. A refused token or a
72+deleted repository isn't the host being down; those show as an error on
73+the link instead. The host is reachable again after three good checks over
74+at least five minutes.
75+
76+By default an unreachable remote is only **shown**: the repository's dot
77+turns amber and a banner offers **Take over**. Nobody is paged and nothing
78+happens on its own. If it's midnight and nobody needs to work, GitHub comes
79+back and the mirror catches up. Nothing needs reconciling, because nobody
80+wrote to g1t.
81+
82+You choose how much more happens, per link, in **Settings → Mirroring**:
83+
84+| Setting | Default | Options |
85+| --- | --- | --- |
86+| When the remote stops answering | Show it on the repository | Also tell the workspace's owners in their [inbox](/guides/inbox/) |
87+| Take over automatically | Off | After 5 to 1440 minutes unreachable |
88+| Hand back on its own | When every branch goes back cleanly | Only when someone hands it back |
89+| Keep CI warm | Off | Run `.g1t/workflows` on each push copied in |
90+| Run the remote's `.github` workflows | On | Off |
91+| Hold workflows that deploy for approval | On | Off |
92+
93+**Hand back on its own** only applies to takeovers g1t started itself. A
94+takeover a person started waits for a person to hand it back.
95+
96+## CI failover
97+
98+Sometimes the remote's git works but its workflows don't run. **Start CI
99+failover** keeps the remote in charge of the code and runs its workflows on
100+g1t for each push copied in:
101+
102+- `.github/workflows` as well as `.g1t/workflows`. When both folders have
103+ a workflow of the same `name:`, g1t's own runs.
104+- Workflows that deploy wait for approval, so nothing deploys from two
105+ places. A workflow deploys when any job names an `environment:`.
106+- Secrets come from the [project on g1t](/guides/secrets-and-variables/),
107+ never the remote's.
108+
109+**End CI failover** when the remote runs its workflows again. Runs already
110+started finish.
111+
112+## Take over
113+
114+**Take over** makes g1t lead the repository for now, whether or not the
115+remote is answering. Everything works on g1t from then on. Each branch's
116+commit at that moment is recorded as where the takeover began.
117+
118+You can take over any time: during an outage g1t detected, a partial one
119+it didn't, or simply because you want to work on g1t for a while.
120+
121+If the remote is still answering, people may keep pushing there. Their
122+work isn't lost. Each branch that changed on both sides waits for a
123+decision when you hand back.
124+
125+## Hand back
126+
127+**Review hand-back** shows what handing back would do, branch by branch,
128+by comparing each side with where the takeover began:
129+
130+| Plan | When | What happens |
131+| --- | --- | --- |
132+| **Push** | Only g1t changed it | g1t's commits are pushed to the remote. |
133+| **Take** | Only the remote changed it | The remote's commits are copied in. |
134+| **Pull request** | Only g1t changed it, and the remote protects the branch | g1t's commits go to `g1t/handback/<branch>` on the remote, with a pull request into the branch. g1t then follows the remote's branch. |
135+| **Diverged** | Both changed it | You decide: keep g1t's (pushed over the remote's), keep the remote's (g1t's is kept under `refs/g1t/replaced/`), or send g1t's as a pull request. |
136+
137+**Hand back** carries out the plan. The repository is read-only while it
138+goes back, so nothing moves under it. If the remote refuses anything, g1t
139+keeps the lead and says why; nobody is left stuck. When every branch has
140+gone back, the mirror stands by again and lists any pull requests it
141+opened.
142+
143+Hand back is refused while the remote isn't answering, and while a
144+diverged branch has no decision.
145+
146+## Move to g1t
147+
148+**Move to g1t** makes the move permanent. The repository stops being a
149+mirror, and **g1t no longer tracks the remote**: pushes made there don't
150+come here any more. You can move from standby, CI failover or during a
151+takeover, and the takeover's work stays as it is.
152+
153+Tick **Keep the remote updated from g1t** to turn the remote into a
154+follower instead of dropping it. g1t then pushes every change to it, as
155+below.
156+
157+## Mirror to other hosts
158+
159+A repository g1t leads can be **mirrored to** any number of followers.
160+Use another g1t to keep a copy on your own servers, or keep GitHub up to
161+date for people still working there.
162+
163+In **Settings → Mirroring**, **Add a remote** with **g1t leads**, its https
164+address, a username if the host needs one, and a token that can push. The
165+token is stored encrypted and never shown again. Repositories imported
166+from GitHub with **Move to g1t** follow g1t this way already.
167+
168+Each push to g1t is sent to every follower. When someone pushes to a
169+follower directly:
170+
171+- **Take in fast-forwards** (the default): a push that builds on g1t's
172+ branch is copied in. Anything else marks the follower **stuck**. g1t
173+ stops pushing to it until someone chooses **Sync now**, which pushes
174+ g1t's branches over it.
175+- **Overwrite with g1t's**: g1t pushes its branches over the change at once.
176+
177+A follower that refuses g1t's push, a protected branch say, is also marked
178+stuck, with its answer on the link.
179+
180+## Self-hosted g1t and g1t.sh
181+
182+The `g1t` remote works both ways between instances:
183+
184+- Run g1t on your own servers as the leader and mirror to
185+ [g1t.sh](https://g1t.sh) for a hosted copy, or the other way round.
186+- Make a standby mirror on your own g1t of a repository on g1t.sh. Take
187+ over if g1t.sh is unreachable from your network.
188+
189+Use a [token](/guides/authentication/) with `code:read` for a leader, or
190+`code:write` for a follower, on the other instance.
191+
192+## API, MCP and events
193+
194+Everything here is also in the API at `/repos/{owner}/{name}/mirror`, and
195+as the `mirror_*` actions of the MCP `repository` tool. See the
196+[API reference](/reference/api/). Reading a repository's
197+mirroring needs read access; syncing needs push; everything else needs the
198+Admin role. Agents never move a repository to g1t or handle a remote's
199+token.
200+
201+[Webhooks](/guides/webhooks/) can subscribe to:
202+
203+- `mirror.unreachable` and `mirror.reachable`
204+- `mirror.state_changed`, sent when a mirror stands by, enters CI
205+ failover, is taken over or is handed back
206+- `mirror.moved_in`, sent when a mirror is moved to g1t or stops because
207+ its remote is gone
208+
209+## When a remote goes away
210+
211+A standby mirror becomes an ordinary repository, keeping what it has, when:
212+
213+- the repository is deleted on GitHub,
214+- g1t's GitHub App is uninstalled from its account, or
215+- a workspace owner removes that GitHub account from the workspace.
216+
217+A takeover in progress keeps going and says why on the link: move it to
218+g1t to keep it.
+15−7
22
33 import { cloneUrl, sshUrl, useAddresses } from "../lib/addresses";
44 import { AgentSetup } from "./agent-setup";
5+import { MirrorCloneNote, useRepoMirror } from "./mirror";
56 import { CopyLine } from "./ui";
67 import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
78
89 /** The ways to get a repository onto a machine or in front of an agent. */
910 export function CloneBox({ path }: { path: string }) {
1011 const addresses = useAddresses();
12+ // A mirror takes no pushes until someone takes over: it says where to push.
13+ const { repo, admin } = useRepoMirror();
14+ const mirror = repo && `${repo.namespace}/${repo.name}` === path ? repo.mirror : null;
15+ const readOnly = mirror != null && mirror.state !== "takeover";
1116 return (
1217 <Tabs defaultValue="https">
1318 <TabsList>
1722 </TabsList>
1823 <TabsContent value="https">
1924 <CopyLine text={cloneUrl(addresses, path)} />
20− <p className="mt-2 text-xs text-muted">
21− To push, use your username and an{" "}
22− <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
23− access token
24− </Link>{" "}
25− as the password.
26− </p>
25+ <MirrorCloneNote mirror={mirror} base={`/${path}`} admin={admin} />
26+ {!readOnly && (
27+ <p className="mt-2 text-xs text-muted">
28+ To push, use your username and an{" "}
29+ <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
30+ access token
31+ </Link>{" "}
32+ as the password.
33+ </p>
34+ )}
2735 </TabsContent>
2836 <TabsContent value="ssh">
2937 <CopyLine text={sshUrl(addresses, path)} disabled />
+6−33
11 import type { GithubRepoLink } from "@g1t/contracts";
2−import { Form } from "react-router";
32 import { siGithub } from "simple-icons";
43
5−import { SubmitButton, TimeAgo } from "./ui";
4+import { TimeAgo } from "./ui";
65
76 /** GitHub's mark (from Simple Icons), for the buttons that go there. */
87 export function GithubMark({ className = "size-4" }: { className?: string }) {
3938 </div>
4039 );
4140 }
42−
43−const LINK_LABEL: Record<GithubRepoLink["mode"], string> = {
44− import: "Imported from",
45− mirror: "Mirrored from",
46− push: "Pushed to",
47−};
4841
4942 /**
50− * Where a repository's code came from on GitHub, on its overview: how it
51− * stays in step, when it last did, and what went wrong if anything did.
43+ * Where a repository's code was imported from on GitHub, on its overview,
44+ * and when. A repository that stays in step with GitHub says so beside its
45+ * name instead, and is managed under Settings → Mirroring.
5246 */
5347 export function GithubLinkStrip({ link }: { link: GithubRepoLink }) {
54− const syncing = link.mode !== "import";
5548 return (
5649 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-line px-4 py-2.5 text-sm">
5750 <span className="flex min-w-0 items-start gap-2 text-muted">
5851 <GithubMark className="mt-0.5 size-4 shrink-0" />
5952 <span className="min-w-0">
60− {LINK_LABEL[link.mode]}{" "}
53+ Imported from{" "}
6154 <a href={`https://github.com/${link.fullName}`} className="font-mono break-all text-fg hover:text-accent" target="_blank" rel="noreferrer">
6255 github.com/{link.fullName}
6356 </a>
6558 </span>
6659 {link.syncedAt && (
6760 <span className="text-xs text-faint">
68− {syncing ? "synced" : "copied"} <TimeAgo at={link.syncedAt} />
61+ copied <TimeAgo at={link.syncedAt} />
6962 </span>
70− )}
71− {syncing && (
72− <Form method="post" className="ml-auto flex items-center gap-3">
73− <SubmitButton
74− name="intent"
75− value="github-sync"
76− pending="Syncing…"
77− className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
78− >
79− Sync now
80− </SubmitButton>
81− <SubmitButton
82− name="intent"
83− value="github-stop"
84− pending="Stopping…"
85− className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
86− >
87− Stop {link.mode === "mirror" ? "mirroring" : "pushing"}
88− </SubmitButton>
89− </Form>
9063 )}
9164 {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>}
9265 </div>
+728−0
1+import { ExternalLink, RefreshCw, Trash2, TriangleAlert } from "lucide-react";
2+import { type ReactNode, useEffect, useId, useState } from "react";
3+import { Form, Link } from "react-router";
4+
5+import type { HandbackPlan, MirrorView, RefDecision, Remote, RepoMirror } from "@g1t/contracts";
6+
7+import { DangerAction, DangerZone } from "./danger-zone";
8+import { MirrorNotes, TakeOverDialog } from "./mirror";
9+import { ConfirmDialog } from "./repo-lifecycle";
10+import { SettingsSection as Section, SettingToggle } from "./settings-section";
11+import { Button, ButtonLink, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
12+import { Badge, type BadgeTone } from "./ui/badge";
13+import { Checkbox, CheckboxOption } from "./ui/checkbox";
14+import { Hint } from "./ui/hint";
15+import { RadioGroup, RadioOption } from "./ui/radio-group";
16+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
17+import {
18+ MIRRORING_DOCS,
19+ TAKE_OVER_MINUTES,
20+ decisionChoices,
21+ decisionField,
22+ handBackReady,
23+ refActionWords,
24+ remoteStateWords,
25+ shortSha,
26+} from "../lib/mirror";
27+
28+/** What a form on the mirroring page came back with. */
29+export type MirrorOutcome = {
30+ intent: string;
31+ ok: boolean;
32+ message: string | null;
33+ error: string | null;
34+ /** What it did that people should know: pull requests it opened, and so on. */
35+ notes?: string[];
36+};
37+
38+const STATE_TONES: Record<string, BadgeTone> = {
39+ standby: "neutral",
40+ ci: "info",
41+ takeover: "warn",
42+ handing_back: "warn",
43+ following: "success",
44+ stuck: "warn",
45+};
46+
47+const PROVIDERS: Record<Remote["provider"], string> = {
48+ github: "GitHub",
49+ g1t: "Another g1t",
50+ git: "A git host",
51+};
52+
53+/** Who put it in this state, in words. */
54+function byWhom(by: string | null): string {
55+ if (!by) return "";
56+ return by === "g1t" ? ", on its own" : `, by ${by}`;
57+}
58+
59+/** A green dot when the remote answers, amber when it does not. */
60+function ReachDot({ reachable }: { reachable: boolean }) {
61+ return (
62+ <span
63+ aria-hidden="true"
64+ className={`inline-block size-2 shrink-0 rounded-full ${reachable ? "bg-success" : "bg-warn shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-warn)_20%,transparent)]"}`}
65+ />
66+ );
67+}
68+
69+function Fact({ label, children }: { label: string; children: ReactNode }) {
70+ return (
71+ <div className="min-w-0">
72+ <dt className="text-xs text-faint">{label}</dt>
73+ <dd className="mt-0.5 text-sm text-fg">{children}</dd>
74+ </div>
75+ );
76+}
77+
78+/** A result line under the form that sent it. */
79+function Result({ result }: { result: MirrorOutcome | undefined }) {
80+ if (!result) return null;
81+ if (!result.ok) return <ErrorText>{result.error}</ErrorText>;
82+ return (
83+ <>
84+ {result.message && (
85+ <p className="text-sm text-success" role="status">
86+ {result.message}
87+ </p>
88+ )}
89+ <MirrorNotes notes={result.notes} />
90+ </>
91+ );
92+}
93+
94+const DOCS_LINK = (
95+ <a href={MIRRORING_DOCS} className="text-fg underline-offset-2 hover:underline">
96+ How mirroring works
97+ </a>
98+);
99+
100+/**
101+ * The page under Settings → Mirroring: for a mirror, the remote that leads,
102+ * what can be done about it and how it behaves; for a repository that
103+ * leads, the remotes that follow it and adding one.
104+ */
105+export function MirroringSettings({
106+ base,
107+ full,
108+ mirror,
109+ view,
110+ planRequested,
111+ planError,
112+ result,
113+}: {
114+ base: string;
115+ full: string;
116+ mirror: RepoMirror | null;
117+ view: MirrorView;
118+ /** Whether the hand-back was asked to be reviewed (the plan is then filled, or `planError` says why not). */
119+ planRequested: boolean;
120+ planError: string | null;
121+ result: MirrorOutcome | undefined;
122+}) {
123+ const leader = view.remotes.find((remote) => remote.role === "leader") ?? null;
124+ const followers = view.remotes.filter((remote) => remote.role === "follower");
125+ const resultFor = (...intents: string[]) => (result && intents.includes(result.intent) ? result : undefined);
126+ if (leader && mirror) {
127+ return (
128+ <div className="max-w-4xl space-y-10">
129+ <Section
130+ title="Where work happens"
131+ about={
132+ <>
133+ {mirror.state === "takeover"
134+ ? `g1t has taken over from ${leader.name}, which leads otherwise. Work here as usual, then hand it back.`
135+ : `${full} follows ${leader.name}, which leads. While it stands by it is an exact, read-only copy that runs nothing. Take over whenever you need to work here.`}{" "}
136+ {DOCS_LINK}.
137+ </>
138+ }
139+ >
140+ <LeaderCard base={base} full={full} remote={leader} mirror={mirror} canManage={view.canManage} result={resultFor("take-over", "ci-on", "ci-off", "sync")} />
141+ </Section>
142+
143+ {(mirror.state === "takeover" || mirror.state === "handing_back") && (
144+ <Section
145+ id="hand-back"
146+ title="Hand back"
147+ about={`When you're done, everything g1t did goes back to ${leader.name}, branch by branch, and ${full} follows it again.`}
148+ >
149+ {mirror.state === "handing_back" ? (
150+ <p className="rounded-xl border border-line bg-surface p-4 text-sm text-muted">
151+ Handing back to {leader.name} now. {full} is read-only until every branch is across; this page shows it
152+ standing by again once it is.
153+ </p>
154+ ) : view.plan ? (
155+ <HandBackForm full={full} remote={leader.name} plan={view.plan} canManage={view.canManage} result={resultFor("hand-back")} />
156+ ) : (
157+ <div className="space-y-3 rounded-xl border border-line bg-surface p-4">
158+ <p className="text-sm text-muted">
159+ g1t compares each branch with {leader.name} and shows what will happen to it before anything goes back.
160+ </p>
161+ {planRequested && planError && <ErrorText>{planError}</ErrorText>}
162+ <ButtonLink to="?plan=1#hand-back" preventScrollReset>
163+ Review hand-back
164+ </ButtonLink>
165+ </div>
166+ )}
167+ </Section>
168+ )}
169+
170+ <LeaderSettings remote={leader} full={full} canManage={view.canManage} result={resultFor("settings")} />
171+
172+ {mirror.state !== "handing_back" && view.canManage && (
173+ <div className="border-t border-line pt-8">
174+ <DangerZone>
175+ <MoveToG1t full={full} remote={leader.name} error={resultFor("move-in")?.error ?? null} />
176+ </DangerZone>
177+ </div>
178+ )}
179+ </div>
180+ );
181+ }
182+ return (
183+ <div className="max-w-4xl space-y-10">
184+ <Section
185+ title="Mirrored to"
186+ about={
187+ <>
188+ Remotes that follow {full}. g1t leads, and every push here goes to each of them. {DOCS_LINK}.
189+ </>
190+ }
191+ >
192+ <Result result={resultFor("sync", "remove", "settings")} />
193+ {followers.length === 0 ? (
194+ <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
195+ Not mirrored anywhere yet.
196+ </p>
197+ ) : (
198+ <>
199+ <ul className="divide-y divide-line rounded-xl border border-line">
200+ {followers.map((remote) => (
201+ <FollowerRow key={remote.id} remote={remote} canManage={view.canManage} />
202+ ))}
203+ </ul>
204+ {view.canManage && (
205+ <Form method="post" className="flex flex-wrap items-center gap-3">
206+ <SubmitButton variant="quiet" name="intent" value="sync" pending="Syncing…">
207+ <RefreshCw size={14} />
208+ Sync now
209+ </SubmitButton>
210+ <span className="text-xs text-muted">Pushes g1t's branches to every remote here now, over any that moved.</span>
211+ </Form>
212+ )}
213+ </>
214+ )}
215+ </Section>
216+
217+ {view.canManage && (
218+ <Section
219+ title="Add a remote"
220+ about={
221+ <>
222+ Another g1t, or any host that speaks git over HTTPS. For GitHub, use{" "}
223+ <Link to="/new/github" className="text-fg underline-offset-2 hover:underline">
224+ New → Import from GitHub
225+ </Link>
226+ .
227+ </>
228+ }
229+ >
230+ <AddRemote result={resultFor("add")} />
231+ </Section>
232+ )}
233+ </div>
234+ );
235+}
236+
237+/** The remote a mirror follows: how it stands, and what can be done now. */
238+function LeaderCard({
239+ base,
240+ full,
241+ remote,
242+ mirror,
243+ canManage,
244+ result,
245+}: {
246+ base: string;
247+ full: string;
248+ remote: Remote;
249+ mirror: RepoMirror;
250+ canManage: boolean;
251+ result: MirrorOutcome | undefined;
252+}) {
253+ const state = mirror.state;
254+ const silent = !remote.reachable;
255+ return (
256+ <div className="space-y-3">
257+ <div className="rounded-xl border border-line bg-surface">
258+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-line px-4 py-3">
259+ <ReachDot reachable={remote.reachable} />
260+ <a
261+ href={remote.url}
262+ target="_blank"
263+ rel="noreferrer"
264+ className="inline-flex min-w-0 items-center gap-1.5 font-mono text-sm font-medium break-all hover:text-accent"
265+ >
266+ {remote.name}
267+ <ExternalLink size={12} className="shrink-0 text-faint" />
268+ </a>
269+ <Badge tone={STATE_TONES[state]}>{remoteStateWords(state)}</Badge>
270+ <span className="ml-auto text-xs text-faint">{PROVIDERS[remote.provider]}</span>
271+ </div>
272+ <dl className="grid gap-x-6 gap-y-3 px-4 py-3 sm:grid-cols-3">
273+ <Fact label="State">
274+ {remoteStateWords(state)}{" "}
275+ <span className="text-muted">
276+ <TimeAgo at={mirror.since} />
277+ {byWhom(remote.stateBy)}
278+ </span>
279+ </Fact>
280+ <Fact label="Remote">
281+ {silent ? (
282+ <span className="text-warn">
283+ Not answering
284+ {remote.unreachableSince && (
285+ <span className="text-muted">
286+ {" "}
287+ for <Since at={remote.unreachableSince} />
288+ </span>
289+ )}
290+ </span>
291+ ) : (
292+ "Answering"
293+ )}
294+ </Fact>
295+ <Fact label="Last synced">{remote.syncedAt ? <TimeAgo at={remote.syncedAt} /> : <span className="text-muted">Not yet</span>}</Fact>
296+ </dl>
297+ {remote.lastError && (
298+ <p className="mx-4 mb-3 flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft">
299+ <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
300+ <span className="min-w-0 break-words">{remote.lastError}</span>
301+ </p>
302+ )}
303+ {canManage && state !== "handing_back" && (
304+ <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3">
305+ {(state === "standby" || state === "ci") && (
306+ <TakeOverDialog
307+ base={base}
308+ full={full}
309+ mirror={mirror}
310+ error={result?.intent === "take-over" ? result.error : null}
311+ trigger={(open) => (
312+ <Button type="button" variant={silent ? "primary" : "quiet"} onClick={open}>
313+ Take over
314+ </Button>
315+ )}
316+ />
317+ )}
318+ {(state === "standby" || state === "ci") && (
319+ <Form method="post" className="contents">
320+ {state === "standby" ? (
321+ <SubmitButton variant="quiet" name="intent" value="ci-on" pending="Starting…">
322+ Start CI failover
323+ </SubmitButton>
324+ ) : (
325+ <SubmitButton variant="quiet" name="intent" value="ci-off" pending="Ending…">
326+ End CI failover
327+ </SubmitButton>
328+ )}
329+ {state === "standby" && (
330+ <SubmitButton variant="quiet" name="intent" value="sync" pending="Syncing…">
331+ <RefreshCw size={14} />
332+ Sync now
333+ </SubmitButton>
334+ )}
335+ </Form>
336+ )}
337+ {state === "takeover" && (
338+ <ButtonLink to="?plan=1#hand-back" preventScrollReset>
339+ Review hand-back
340+ </ButtonLink>
341+ )}
342+ </div>
343+ )}
344+ </div>
345+ <p className="text-xs text-muted">
346+ {state === "standby"
347+ ? `CI failover keeps the code on ${remote.name} and runs its workflows here, results going back. Taking over makes g1t lead until you hand it back.`
348+ : state === "ci"
349+ ? `${remote.name} keeps the code; g1t runs its workflows. ${full} stays read-only for code, issues and pull requests.`
350+ : state === "takeover"
351+ ? `Everything works here. Nothing goes back to ${remote.name} until you hand it back.`
352+ : `Read-only until every branch is back on ${remote.name}.`}
353+ </p>
354+ {/* A refused takeover says why in its dialog, which opens again. */}
355+ {(result?.ok || result?.intent !== "take-over") && <Result result={result} />}
356+ </div>
357+ );
358+}
359+
360+/** How long since: "5 minutes", "3 hours". */
361+function Since({ at }: { at: string }) {
362+ const minutes = Math.max(1, Math.round((Date.now() - new Date(at).getTime()) / 60_000));
363+ const words =
364+ minutes < 60
365+ ? `${minutes} minute${minutes === 1 ? "" : "s"}`
366+ : minutes < 48 * 60
367+ ? `${Math.round(minutes / 60)} hour${Math.round(minutes / 60) === 1 ? "" : "s"}`
368+ : `${Math.round(minutes / 1440)} days`;
369+ return (
370+ <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
371+ {words}
372+ </time>
373+ );
374+}
375+
376+/** Each branch, what happens to it, and a choice for those both sides moved. */
377+function HandBackForm({
378+ full,
379+ remote,
380+ plan,
381+ canManage,
382+ result,
383+}: {
384+ full: string;
385+ remote: string;
386+ plan: HandbackPlan;
387+ canManage: boolean;
388+ result: MirrorOutcome | undefined;
389+}) {
390+ const [chosen, setChosen] = useState<Record<string, RefDecision | undefined>>({});
391+ const moving = plan.refs.filter((ref) => ref.action !== "same");
392+ const same = plan.refs.length - moving.length;
393+ const ready = handBackReady(plan, chosen);
394+ const choices = decisionChoices(remote);
395+ return (
396+ <Form method="post" preventScrollReset className="space-y-4">
397+ <input type="hidden" name="intent" value="hand-back" />
398+ {!plan.reachable && (
399+ <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
400+ <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
401+ {remote} isn't answering yet; hand back once it is.
402+ </p>
403+ )}
404+ {moving.length === 0 ? (
405+ <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
406+ Every branch is already the same on both sides. Handing back only makes {remote} lead again.
407+ </p>
408+ ) : (
409+ <div className="overflow-hidden rounded-xl border border-line">
410+ <div className="hidden grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] gap-4 border-b border-line bg-surface px-4 py-2 text-xs font-medium text-muted sm:grid">
411+ <span>Branch</span>
412+ <span>g1t</span>
413+ <span className="truncate">{remote.split("/")[0]}</span>
414+ <span>What happens</span>
415+ </div>
416+ <ul className="divide-y divide-line">
417+ {moving.map((ref) => (
418+ <li
419+ key={ref.ref}
420+ className="grid gap-x-4 gap-y-1.5 px-4 py-3 text-sm sm:grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] sm:items-start"
421+ >
422+ <span className="min-w-0 truncate font-mono font-medium">{ref.ref}</span>
423+ <div className="flex gap-4 sm:contents">
424+ <span className="font-mono text-xs text-muted sm:pt-0.5">
425+ <span className="text-faint sm:hidden">g1t </span>
426+ {shortSha(ref.ours)}
427+ </span>
428+ <span className="font-mono text-xs text-muted sm:pt-0.5">
429+ <span className="text-faint sm:hidden">{remote.split("/")[0]} </span>
430+ {shortSha(ref.theirs)}
431+ </span>
432+ </div>
433+ {ref.action === "diverged" ? (
434+ <div className="space-y-1.5">
435+ <p className="flex items-center gap-1.5 text-warn">
436+ <TriangleAlert size={13} aria-hidden="true" />
437+ Both moved: choose
438+ </p>
439+ <RadioGroup
440+ name={decisionField(ref.ref)}
441+ value={chosen[ref.ref] ?? ref.decision ?? undefined}
442+ onValueChange={(value) => setChosen((now) => ({ ...now, [ref.ref]: value as RefDecision }))}
443+ disabled={!canManage}
444+ aria-label={`What happens to ${ref.ref}`}
445+ className="gap-1.5"
446+ >
447+ {choices.map((choice) => (
448+ <RadioOption key={choice.value} value={choice.value} label={choice.label} />
449+ ))}
450+ </RadioGroup>
451+ </div>
452+ ) : (
453+ <span className={ref.action === "pull_request" ? "text-info" : "text-fg-soft"}>
454+ {refActionWords(ref.action, remote)}
455+ {ref.action === "pull_request" && (
456+ <span className="mt-0.5 block font-mono text-xs text-muted">g1t/handback/{ref.ref}</span>
457+ )}
458+ </span>
459+ )}
460+ </li>
461+ ))}
462+ </ul>
463+ </div>
464+ )}
465+ {same > 0 && moving.length > 0 && (
466+ <p className="text-xs text-muted">
467+ {same} other branch{same === 1 ? " is" : "es are"} already the same on both sides.
468+ </p>
469+ )}
470+ <Result result={result} />
471+ {canManage && (
472+ <div className="flex flex-wrap items-center gap-3">
473+ <SubmitButton variant="accent" disabled={!ready} pending="Handing back…">
474+ Hand back
475+ </SubmitButton>
476+ <span className="text-xs text-muted">
477+ {ready
478+ ? `${remote} leads again once every branch is across. Until then ${full} is read-only.`
479+ : plan.reachable
480+ ? "Choose what happens to each branch both sides moved."
481+ : `Waiting for ${remote} to answer.`}
482+ </span>
483+ </div>
484+ )}
485+ </Form>
486+ );
487+}
488+
489+/** How a mirror behaves when its remote stops answering, and what runs in CI failover and takeovers. */
490+function LeaderSettings({
491+ remote,
492+ full,
493+ canManage,
494+ result,
495+}: {
496+ remote: Remote;
497+ full: string;
498+ canManage: boolean;
499+ result: MirrorOutcome | undefined;
500+}) {
501+ const settings = remote.settings;
502+ const [auto, setAuto] = useState(settings.takeOverAfter != null);
503+ const id = useId();
504+ const [least, most] = TAKE_OVER_MINUTES;
505+ return (
506+ <Form method="post" className="space-y-10">
507+ <input type="hidden" name="intent" value="settings" />
508+ <input type="hidden" name="kind" value="leader" />
509+ <input type="hidden" name="remoteId" value={remote.id} />
510+ <fieldset disabled={!canManage} className="space-y-10">
511+ <Section
512+ title={`When ${remote.name} stops answering`}
513+ about="g1t checks every minute. Unless you choose otherwise, it only says so here: nobody is paged, and nothing happens on its own."
514+ >
515+ <RadioGroup name="notify" defaultValue={settings.notify} className="gap-3">
516+ <RadioOption value="banner" label="Show it on the repository" description={`A line across ${full}'s pages, and an amber dot beside its name.`} />
517+ <RadioOption value="inbox" label="Also tell workspace owners in their inbox" description="Once when it stops answering, and once when it answers again." />
518+ </RadioGroup>
519+ <div className="rounded-xl border border-line bg-surface p-4">
520+ <div className="flex flex-wrap items-center gap-x-2.5 gap-y-2">
521+ <Checkbox id={`${id}-auto`} name="autoTakeOver" checked={auto} onCheckedChange={(checked) => setAuto(checked === true)} />
522+ <label htmlFor={`${id}-auto`} className="cursor-pointer text-sm font-medium">
523+ Take over automatically after
524+ </label>
525+ <span className="flex items-center gap-2">
526+ <span className="w-20">
527+ <Input
528+ type="number"
529+ name="takeOverAfter"
530+ aria-label="Minutes"
531+ min={least}
532+ max={most}
533+ step={1}
534+ defaultValue={settings.takeOverAfter ?? 30}
535+ disabled={!auto}
536+ />
537+ </span>
538+ <span className="text-sm text-muted">minutes</span>
539+ </span>
540+ </div>
541+ <p className="mt-1.5 pl-6.5 text-xs text-faint">
542+ Off unless you turn it on: people take over when they want. From {least} minutes to a day.
543+ </p>
544+ </div>
545+ <SettingToggle name="handBackWhenClean" on={settings.handBack === "when_clean"} title="Hand back on its own when every branch goes back cleanly">
546+ Only after an automatic takeover. A branch that moved on both sides always waits for someone to choose.
547+ </SettingToggle>
548+ </Section>
549+
550+ <Section title="Workflows" about="What runs on g1t during CI failover and takeovers. A mirror standing by runs nothing unless CI is kept warm.">
551+ <SettingToggle name="keepCiWarm" on={settings.keepCiWarm} title="Keep CI warm">
552+ Run .g1t/workflows on every push copied in from {remote.name}, so CI is ready the moment you need it.
553+ </SettingToggle>
554+ <SettingToggle name="githubWorkflows" on={settings.githubWorkflows} title={`Run ${remote.name}'s .github workflows`}>
555+ In CI failover and takeovers, workflows in .github/workflows run on g1t too, beside .g1t/workflows.
556+ </SettingToggle>
557+ <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval">
558+ A job that deploys waits for someone to approve it, so taking over never ships anything by surprise.
559+ </SettingToggle>
560+ </Section>
561+ </fieldset>
562+ {canManage && (
563+ <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
564+ <SubmitButton pending="Saving…">Save settings</SubmitButton>
565+ {result?.ok && <span className="text-sm text-muted">Saved.</span>}
566+ <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
567+ </div>
568+ )}
569+ </Form>
570+ );
571+}
572+
573+/** Making g1t the leader for good, said plainly, with the remote kept as a follower if they like. */
574+function MoveToG1t({ full, remote, error }: { full: string; remote: string; error: string | null }) {
575+ return (
576+ <ConfirmDialog
577+ intent="move-in"
578+ title={`Move ${full} to g1t?`}
579+ description={`g1t will no longer track ${remote}. Pushes made there won't come here.`}
580+ confirm={full}
581+ submit="Move to g1t"
582+ busy="Moving…"
583+ error={error}
584+ trigger={(open) => (
585+ <DangerAction
586+ title="Move to g1t"
587+ action={
588+ <Button type="button" variant="danger" onClick={open}>
589+ Move to g1t
590+ </Button>
591+ }
592+ >
593+ Make g1t lead {full} for good. It stops being a mirror, and g1t stops tracking {remote}.
594+ </DangerAction>
595+ )}
596+ extra={
597+ <CheckboxOption
598+ name="keepRemoteUpdated"
599+ label={`Keep ${remote} updated from g1t`}
600+ description={`${remote} follows instead: every push here is pushed there. Off, g1t leaves it as it is.`}
601+ />
602+ }
603+ >
604+ <li>{full} stops being a mirror: pushes, pull requests, issues, agents and workflows work here from now on.</li>
605+ <li>This is for good. There is nothing to hand back afterwards.</li>
606+ </ConfirmDialog>
607+ );
608+}
609+
610+/** One remote that follows: how it stands, what happens to pushes made on it, and removing it. */
611+function FollowerRow({ remote, canManage }: { remote: Remote; canManage: boolean }) {
612+ const stuck = remote.state === "stuck";
613+ return (
614+ <li className="space-y-2 px-4 py-3">
615+ <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
616+ <ReachDot reachable={remote.reachable} />
617+ <a href={remote.url} target="_blank" rel="noreferrer" className="min-w-0 font-mono text-sm font-medium break-all hover:text-accent">
618+ {remote.name}
619+ </a>
620+ <Hint label={stuck ? "It refused a push or moved on its own. Sync now pushes g1t's branches over it." : null}>
621+ <Badge tone={STATE_TONES[remote.state] ?? "neutral"} tabIndex={stuck ? 0 : undefined}>
622+ {remoteStateWords(remote.state)}
623+ </Badge>
624+ </Hint>
625+ {canManage && (
626+ <span className="ml-auto">
627+ <ConfirmDialog
628+ intent="remove"
629+ fields={{ remoteId: remote.id }}
630+ title={`Stop mirroring to ${remote.name}?`}
631+ description={`${remote.name} keeps what it has; g1t stops pushing to it. Its token is deleted.`}
632+ submit="Remove"
633+ busy="Removing…"
634+ trigger={(open) => (
635+ <Button type="button" variant="quiet" onClick={open} aria-label={`Remove ${remote.name}`}>
636+ <Trash2 size={14} />
637+ <span className="hidden sm:inline">Remove</span>
638+ </Button>
639+ )}
640+ />
641+ </span>
642+ )}
643+ </div>
644+ <p className="text-xs text-faint">
645+ {PROVIDERS[remote.provider]} · {remote.syncedAt ? <>Synced <TimeAgo at={remote.syncedAt} /></> : "Not synced yet"}
646+ {!remote.reachable && <span className="text-warn"> · Not answering</span>}
647+ </p>
648+ {remote.lastError && (
649+ <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft">
650+ <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
651+ <span className="min-w-0 break-words">{remote.lastError}</span>
652+ </p>
653+ )}
654+ {canManage && (
655+ <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
656+ <input type="hidden" name="intent" value="settings" />
657+ <input type="hidden" name="kind" value="follower" />
658+ <input type="hidden" name="remoteId" value={remote.id} />
659+ <span className="text-muted">Pushes made on {remote.name}:</span>
660+ <Select name="remotePushes" defaultValue={remote.settings.remotePushes}>
661+ <SelectTrigger size="sm" aria-label={`Pushes made on ${remote.name}`} className="w-auto min-w-44">
662+ <SelectValue />
663+ </SelectTrigger>
664+ <SelectContent>
665+ <SelectItem value="adopt">Take in fast-forwards</SelectItem>
666+ <SelectItem value="overwrite">Overwrite with g1t's</SelectItem>
667+ </SelectContent>
668+ </Select>
669+ <SubmitButton
670+ variant="quiet"
671+ match={{ intent: "settings", remoteId: remote.id }}
672+ pending="Saving…"
673+ className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
674+ >
675+ Save
676+ </SubmitButton>
677+ </Form>
678+ )}
679+ </li>
680+ );
681+}
682+
683+/** A remote to add: where, which one leads, and how to sign in to it. */
684+function AddRemote({ result }: { result: MirrorOutcome | undefined }) {
685+ const [round, setRound] = useState(0);
686+ // Cleared once added, ready for the next.
687+ useEffect(() => {
688+ if (result?.ok) setRound((n) => n + 1);
689+ }, [result]);
690+ return (
691+ <Form key={round} method="post" className="space-y-5 rounded-xl border border-line bg-surface p-4">
692+ <input type="hidden" name="intent" value="add" />
693+ <fieldset className="space-y-2">
694+ <legend className="mb-1.5 text-sm font-medium text-muted">Provider</legend>
695+ <RadioGroup name="provider" defaultValue="git" className="gap-2.5 sm:grid-cols-2">
696+ <RadioOption value="g1t" label="Another g1t" description="A repository on another g1t, such as one you host yourself." />
697+ <RadioOption value="git" label="Any git host" description="Anywhere that takes git over HTTPS." />
698+ </RadioGroup>
699+ </fieldset>
700+ <fieldset className="space-y-2">
701+ <legend className="mb-1.5 text-sm font-medium text-muted">Which one leads</legend>
702+ <RadioGroup name="role" defaultValue="follower" className="gap-2.5">
703+ <RadioOption value="follower" label="g1t leads; the remote follows" description="Every push here is pushed there." />
704+ <RadioOption
705+ value="leader"
706+ label="The remote leads; this repository mirrors it"
707+ description="Only for an empty repository: g1t copies the remote, and this repository becomes a read-only mirror of it."
708+ />
709+ </RadioGroup>
710+ </fieldset>
711+ <Field label="HTTPS URL">
712+ <Input name="url" type="url" required inputMode="url" spellCheck={false} placeholder="https://git.example.com/acme/web.git" />
713+ </Field>
714+ <div className="grid gap-4 sm:grid-cols-2">
715+ <Field label="Username (optional)">
716+ <Input name="username" spellCheck={false} autoCapitalize="off" />
717+ </Field>
718+ <Field label="Token" hint="Stored encrypted; never shown again.">
719+ <Input name="token" type="password" spellCheck={false} />
720+ </Field>
721+ </div>
722+ <Result result={result} />
723+ <SubmitButton pending="Adding…" match={{ intent: "add" }}>
724+ Add remote
725+ </SubmitButton>
726+ </Form>
727+ );
728+}
+391−0
1+import { CloudOff, Flag, Undo2, Workflow } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Form, Link, useRouteLoaderData } from "react-router";
4+
5+import type { RepoMirror } from "@g1t/contracts";
6+
7+import { ConfirmDialog } from "./repo-lifecycle";
8+import { SubmitButton, TimeAgo } from "./ui";
9+import { Badge } from "./ui/badge";
10+import { Hint } from "./ui/hint";
11+import {
12+ type MirrorBrief,
13+ type MirroredRepo,
14+ followersOf,
15+ leaderOf,
16+ mirrorBadge,
17+ mirrorBanner,
18+ mirrorReason,
19+ mirroringSettings,
20+ workflowReason,
21+} from "../lib/mirror";
22+
23+/**
24+ * The repository the page is in, its remotes in brief and whether the
25+ * viewer manages them, from the repository layout's loader. Empty outside
26+ * a repository.
27+ */
28+export function useRepoMirror(): { repo: MirroredRepo | null; briefs: MirrorBrief[]; admin: boolean } {
29+ const layout = useRouteLoaderData("routes/repo/layout") as
30+ | { repo?: MirroredRepo; mirrorBriefs?: MirrorBrief[]; access?: { can?: { manage_integrations?: boolean } } }
31+ | undefined;
32+ return {
33+ repo: layout?.repo ?? null,
34+ briefs: layout?.mirrorBriefs ?? [],
35+ admin: Boolean(layout?.access?.can?.manage_integrations),
36+ };
37+}
38+
39+/**
40+ * Why pushing, merging, opening issues and pull requests and assigning
41+ * agents are off in the repository the page is in; null when they are not
42+ * off because it is a mirror.
43+ */
44+export function useMirrorReason(): string | null {
45+ return mirrorReason(useRepoMirror().repo);
46+}
47+
48+/** Why running workflows is off: a mirror standing by or handing back runs nothing. */
49+export function useWorkflowReason(): string | null {
50+ return workflowReason(useRepoMirror().repo);
51+}
52+
53+/** What the badge means, said on hover. */
54+function badgeHint(mirror: RepoMirror | null | undefined, briefs: readonly MirrorBrief[]): string {
55+ if (mirror) {
56+ switch (mirror.state) {
57+ case "standby":
58+ return `A read-only copy that follows ${mirror.remote}. Work happens there; clone and fetch freely here.`;
59+ case "ci":
60+ return `${mirror.remote} keeps the code; g1t runs its workflows and sends the results back.`;
61+ case "takeover":
62+ return `g1t leads for now: everything works here until it is handed back to ${mirror.remote}.`;
63+ case "handing_back":
64+ return `Going back to ${mirror.remote} branch by branch. Read-only until it is done.`;
65+ }
66+ }
67+ const names = followersOf(briefs).map((brief) => brief.name);
68+ return `g1t leads. Every push here goes to ${names.join(", ")}.`;
69+}
70+
71+/**
72+ * Beside the repository's name: what it is to its remotes. An amber dot
73+ * when the remote a mirror follows is not answering.
74+ */
75+export function MirrorBadge({ mirror, briefs }: { mirror: RepoMirror | null | undefined; briefs: readonly MirrorBrief[] }) {
76+ const badge = mirrorBadge(mirror, briefs);
77+ if (!badge) return null;
78+ const silent = Boolean(mirror) && leaderOf(briefs)?.reachable === false;
79+ return (
80+ <Hint label={badgeHint(mirror, briefs)}>
81+ <Badge tone={badge.tone} tabIndex={0} className="min-w-0 max-w-full px-2 py-0.5 text-xs font-normal">
82+ {silent && <span aria-label="Not answering" className="size-1.5 shrink-0 rounded-full bg-warn" />}
83+ <span className="truncate">{badge.label}</span>
84+ {badge.more > 0 && <span className="shrink-0 opacity-70">+{badge.more} more</span>}
85+ </Badge>
86+ </Hint>
87+ );
88+}
89+
90+const BANNER_TONES = {
91+ warn: { box: "border-warn/40 bg-warn/5", icon: "text-warn", action: "text-warn" },
92+ info: { box: "border-info/40 bg-info/5", icon: "text-info", action: "text-info" },
93+} as const;
94+
95+/** A compact line across the top of the page, with what it is about and one action. */
96+function BannerBox({ tone, icon, title, children, action, className = "" }: {
97+ tone: keyof typeof BANNER_TONES;
98+ className?: string;
99+ icon: ReactNode;
100+ title: ReactNode;
101+ children?: ReactNode;
102+ action?: ReactNode;
103+}) {
104+ const colours = BANNER_TONES[tone];
105+ return (
106+ <div
107+ role="status"
108+ className={`flex flex-col gap-2 rounded-lg border px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between ${colours.box} ${className}`}
109+ >
110+ <p className="flex min-w-0 gap-2.5">
111+ <span className={`mt-0.5 shrink-0 ${colours.icon} [&_svg]:size-4`}>{icon}</span>
112+ <span className="min-w-0">
113+ <span className="font-medium text-fg">{title}</span>
114+ {children && <> <span className="text-muted">{children}</span></>}
115+ </span>
116+ </p>
117+ {action && <div className={`shrink-0 pl-6.5 font-medium sm:pl-0 ${colours.action}`}>{action}</div>}
118+ </div>
119+ );
120+}
121+
122+const BANNER_ACTION = "inline-flex items-center gap-1.5 font-medium hover:underline disabled:opacity-50";
123+
124+/**
125+ * Taking over, from anywhere: says what it does and posts to the
126+ * mirroring settings, which then show it.
127+ */
128+export function TakeOverDialog({
129+ base,
130+ full,
131+ mirror,
132+ error,
133+ trigger,
134+}: {
135+ base: string;
136+ full: string;
137+ mirror: Pick<RepoMirror, "remote" | "holdDeploys">;
138+ error?: string | null;
139+ trigger: (open: () => void) => ReactNode;
140+}) {
141+ return (
142+ <ConfirmDialog
143+ intent="take-over"
144+ action={mirroringSettings(base)}
145+ title={`Take over ${full}?`}
146+ description={`g1t leads ${full} until you hand it back. Pushes, pull requests, agents and workflows work here meanwhile.`}
147+ submit="Take over"
148+ busy="Taking over…"
149+ danger={false}
150+ error={error}
151+ trigger={trigger}
152+ >
153+ <li>{mirror.remote} keeps what it has. Nothing goes back to it until you hand it back, branch by branch.</li>
154+ {mirror.holdDeploys !== false && <li>Workflows that deploy wait for someone to approve them.</li>}
155+ </ConfirmDialog>
156+ );
157+}
158+
159+/**
160+ * Across the top of a mirror's pages, when there is something to know: its
161+ * remote is not answering, g1t runs its CI, has taken over, or is handing
162+ * back. A mirror standing by whose remote answers gets none: the badge
163+ * beside its name says it.
164+ */
165+export function MirrorBanner({
166+ base,
167+ full,
168+ mirror,
169+ briefs,
170+ admin,
171+ className,
172+}: {
173+ base: string;
174+ full: string;
175+ mirror: RepoMirror | null | undefined;
176+ briefs: readonly MirrorBrief[];
177+ /** Whether the viewer manages its mirroring: they get its one action. */
178+ admin: boolean;
179+ className?: string;
180+}) {
181+ const kind = mirrorBanner(mirror, briefs);
182+ if (!kind || !mirror) return null;
183+ const settings = mirroringSettings(base);
184+ switch (kind) {
185+ case "unreachable": {
186+ const synced = leaderOf(briefs)?.syncedAt;
187+ return (
188+ <BannerBox
189+ className={className}
190+ tone="warn"
191+ icon={<CloudOff />}
192+ title={`${mirror.remote} isn't answering.`}
193+ action={
194+ admin ? (
195+ <TakeOverDialog
196+ base={base}
197+ full={full}
198+ mirror={mirror}
199+ trigger={(open) => (
200+ <button type="button" onClick={open} className={BANNER_ACTION}>
201+ Take over
202+ </button>
203+ )}
204+ />
205+ ) : undefined
206+ }
207+ >
208+ {synced ? (
209+ <>
210+ g1t has its copy as of <TimeAgo at={synced} />.
211+ </>
212+ ) : (
213+ "g1t has its copy as of the last sync."
214+ )}
215+ {mirror.state === "ci" && " Its workflows keep running here."}
216+ </BannerBox>
217+ );
218+ }
219+ case "ci":
220+ return (
221+ <BannerBox
222+ className={className}
223+ tone="info"
224+ icon={<Workflow />}
225+ title={`Running ${mirror.remote}'s workflows on g1t.`}
226+ action={
227+ admin ? (
228+ <Form method="post" action={settings}>
229+ <SubmitButton name="intent" value="ci-off" pending="Ending…" className={BANNER_ACTION}>
230+ End CI failover
231+ </SubmitButton>
232+ </Form>
233+ ) : undefined
234+ }
235+ >
236+ CI failover started <TimeAgo at={mirror.since} />.
237+ {mirror.holdDeploys !== false && " Deploying workflows wait for approval."}
238+ </BannerBox>
239+ );
240+ case "takeover":
241+ return (
242+ <BannerBox
243+ className={className}
244+ tone="warn"
245+ icon={<Flag />}
246+ title={`g1t is leading ${full} for now.`}
247+ action={
248+ admin ? (
249+ <Link to={`${settings}?plan=1#hand-back`} className={BANNER_ACTION}>
250+ Hand back…
251+ </Link>
252+ ) : undefined
253+ }
254+ >
255+ Everything works here; when you're done, hand it back to {mirror.remote}.
256+ </BannerBox>
257+ );
258+ case "handing_back":
259+ return (
260+ <BannerBox
261+ className={className}
262+ tone="warn"
263+ icon={<Undo2 />}
264+ title={`Handing back to ${mirror.remote}…`}
265+ action={
266+ admin ? (
267+ <Link to={settings} className={BANNER_ACTION}>
268+ Progress
269+ </Link>
270+ ) : undefined
271+ }
272+ >
273+ {full} is read-only until it's done.
274+ </BannerBox>
275+ );
276+ }
277+}
278+
279+/**
280+ * Under the clone address: what pushing here does when the repository
281+ * mirrors a remote. Null when it leads.
282+ */
283+export function MirrorCloneNote({ mirror, base, admin }: { mirror: RepoMirror | null | undefined; base: string; admin: boolean }) {
284+ if (!mirror) return null;
285+ const where = admin ? (
286+ <Link to={mirroringSettings(base)} className="text-fg underline underline-offset-4">
287+ Settings → Mirroring
288+ </Link>
289+ ) : (
290+ "Settings → Mirroring"
291+ );
292+ return (
293+ <p className="mt-2 text-xs text-muted">
294+ {mirror.state === "takeover" ? (
295+ <>g1t leads for now: pushes here go back to {mirror.remote} when it is handed back.</>
296+ ) : mirror.state === "handing_back" ? (
297+ <>Handing back to {mirror.remote}: clone and fetch freely; pushes wait until it is done.</>
298+ ) : (
299+ <>
300+ A mirror of {mirror.remote}. Clone and fetch freely; to push, push there, or take over in {where}.
301+ </>
302+ )}
303+ </p>
304+ );
305+}
306+
307+/**
308+ * On the repository's overview, one quiet line when no banner says it: a
309+ * mirror standing by, or the remotes that follow it, and when it last synced.
310+ */
311+export function MirrorOverviewNote({
312+ base,
313+ mirror,
314+ briefs,
315+ admin,
316+ syncedAt,
317+ lastError,
318+}: {
319+ base: string;
320+ mirror: RepoMirror | null | undefined;
321+ briefs: readonly MirrorBrief[];
322+ admin: boolean;
323+ syncedAt?: string | null;
324+ lastError?: string | null;
325+}) {
326+ const followers = followersOf(briefs);
327+ // A banner says it already.
328+ if (mirror && mirrorBanner(mirror, briefs)) return null;
329+ if (!mirror && followers.length === 0) return null;
330+ const stuck = followers.filter((brief) => brief.state === "stuck");
331+ return (
332+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted">
333+ <p className="min-w-0">
334+ {mirror ? (
335+ <>
336+ A read-only mirror of{" "}
337+ <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono break-all text-fg hover:text-accent">
338+ {mirror.remote}
339+ </a>
340+ .
341+ </>
342+ ) : (
343+ <>
344+ Mirrored to <span className="font-mono text-fg">{followers[0]!.name}</span>
345+ {followers.length > 1 && ` and ${followers.length - 1} more`}: every push here goes there too.
346+ </>
347+ )}
348+ {syncedAt && (
349+ <span className="text-faint">
350+ {" "}
351+ Synced <TimeAgo at={syncedAt} />.
352+ </span>
353+ )}
354+ </p>
355+ {admin && (
356+ <Link to={mirroringSettings(base)} className="text-xs text-muted hover:text-fg">
357+ Mirroring settings
358+ </Link>
359+ )}
360+ {(lastError || stuck.length > 0) && (
361+ <p className="w-full text-xs text-warn">
362+ {lastError ?? `${stuck.map((brief) => brief.name).join(", ")} stopped taking pushes. See Settings → Mirroring.`}
363+ </p>
364+ )}
365+ </div>
366+ );
367+}
368+
369+/** What an action did that people should know, such as the pull requests it opened, with addresses as links. */
370+export function MirrorNotes({ notes }: { notes: readonly string[] | null | undefined }) {
371+ if (!notes || notes.length === 0) return null;
372+ return (
373+ <div role="status" className="rounded-lg border border-success/40 bg-success/5 px-4 py-3 text-sm">
374+ <ul className="space-y-1">
375+ {notes.map((note, at) => (
376+ <li key={at} className="text-fg-soft">
377+ {note.split(/(https?:\/\/\S+?)(?=[.,;)]*(?:\s|$))/).map((piece, index) =>
378+ /^https?:\/\//.test(piece) ? (
379+ <a key={index} href={piece} className="break-all text-fg underline underline-offset-2 hover:text-accent">
380+ {piece}
381+ </a>
382+ ) : (
383+ piece
384+ ),
385+ )}
386+ </li>
387+ ))}
388+ </ul>
389+ </div>
390+ );
391+}
+4−0
3535 danger = true,
3636 action,
3737 trigger,
38+ extra,
3839 }: {
3940 intent: string;
4041 /** Other hidden fields to post with it. */
5354 /** Where to post, when not the page's own action. */
5455 action?: string;
5556 trigger: (open: () => void) => ReactNode;
57+ /** More fields to post, under the list: a choice that goes with it. */
58+ extra?: ReactNode;
5659 }) {
5760 const [open, setOpen] = useState(Boolean(error));
5861 const [typed, setTyped] = useState("");
8992 {description && <AlertDialogDescription>{description}</AlertDialogDescription>}
9093 </AlertDialogHeader>
9194 {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>}
95+ {extra}
9296 {confirm != null && (
9397 <FormField>
9498 <FieldLabel htmlFor={`${id}-confirm`}>
+1−0
99 agents: { title: "Agents", about: "How g1t picks up work here, and what it reads first." },
1010 guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." },
1111 repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." },
12+ mirroring: { title: "Mirroring", about: "Copies of this repository on other hosts: which one leads, taking over and handing back, and remotes that follow it." },
1213 access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." },
1314 keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." },
1415 branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." },
+6−1
1−import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, ArrowLeftRight, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
10951095 Repository
10961096 </SidebarLink>
10971097 )}
1098+ {shows("mirroring") && (
1099+ <SidebarLink to={`${base}/settings/mirroring`} icon={<ArrowLeftRight size={15} />}>
1100+ Mirroring
1101+ </SidebarLink>
1102+ )}
10981103 {shows("access") && (
10991104 <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}>
11001105 Access
+2−0
3737 runners: "manage_integrations",
3838 deployments: "manage_integrations",
3939 domains: "manage_integrations",
40+ // Taking over, handing back and adding remotes: Admins.
41+ mirroring: "manage_integrations",
4042 dependencies: "manage_settings",
4143 // Write and up can see who has access; Admins change it.
4244 access: "push",
+3−3
8181 },
8282 {
8383 id: "mirror",
84− title: "Mirror",
85− text: "Keep the code on GitHub. Every push there is fetched into g1t, so agents can work on it, and deployments build it once you turn them on.",
84+ title: "Standby mirror",
85+ text: "g1t keeps a read-only copy that follows GitHub. Take over whenever you need to work here.",
8686 },
8787 {
8888 id: "push",
8989 title: "Move to g1t",
90− text: "Work on g1t from now on. Every push here is pushed to GitHub, so it stays up to date for everyone else.",
90+ text: "g1t leads; GitHub follows every push.",
9191 },
9292 ] as const;
+136−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { HandbackPlan, RepoMirror } from "@g1t/contracts";
5+
6+import { DEFAULT_MIRROR_SETTINGS, TAKE_OVER_AFTER_MINUTES, mirrorWritable } from "../../../../packages/contracts/src/mirrors.ts";
7+
8+import {
9+ clampMinutes,
10+ decisionsFrom,
11+ handBackReady,
12+ mirrorBadge,
13+ mirrorBanner,
14+ mirrorReason,
15+ mirrorSettingsFrom,
16+ shortSha,
17+ TAKE_OVER_MINUTES,
18+ workflowReason,
19+ writable,
20+} from "./mirror.ts";
21+
22+const mirror = (state: RepoMirror["state"]): RepoMirror => ({
23+ state,
24+ remote: "github.com/acme/web",
25+ url: "https://github.com/acme/web",
26+ since: "2026-10-08T10:00:00Z",
27+});
28+const repo = (state: RepoMirror["state"] | null) => ({ namespace: "acme", name: "web", mirror: state ? mirror(state) : null });
29+const leader = (reachable: boolean) => ({ repoId: "r", role: "leader" as const, name: "github.com/acme/web", state: "standby" as const, reachable });
30+const follower = (name: string) => ({ repoId: "r", role: "follower" as const, name, state: "following" as const, reachable: true });
31+
32+test("the copies agree with the contracts", () => {
33+ for (const state of [null, "standby", "ci", "takeover", "handing_back"] as const) {
34+ assert.equal(writable(state ? mirror(state) : null), mirrorWritable(state ? mirror(state) : null));
35+ }
36+ assert.deepEqual([...TAKE_OVER_MINUTES], [...TAKE_OVER_AFTER_MINUTES]);
37+});
38+
39+test("a read-only mirror says why its buttons are off, and where to take over", () => {
40+ assert.equal(
41+ mirrorReason(repo("standby")),
42+ "acme/web is a mirror of github.com/acme/web. Work happens there until someone takes over in Settings → Mirroring.",
43+ );
44+ assert.equal(mirrorReason(repo("ci")), mirrorReason(repo("standby")));
45+ assert.equal(mirrorReason(repo("handing_back")), "acme/web is handing back to github.com/acme/web; it takes changes again when that is done.");
46+ assert.equal(mirrorReason(repo("takeover")), null);
47+ assert.equal(mirrorReason(repo(null)), null);
48+ assert.equal(mirrorReason(null), null);
49+});
50+
51+test("workflows run in CI failover and takeovers, not while standing by or handing back", () => {
52+ assert.equal(workflowReason(repo("ci")), null);
53+ assert.equal(workflowReason(repo("takeover")), null);
54+ assert.ok(workflowReason(repo("standby")));
55+ assert.ok(workflowReason(repo("handing_back")));
56+ assert.equal(workflowReason(repo(null)), null);
57+});
58+
59+test("the badge says what the repository is to its remotes", () => {
60+ assert.deepEqual(mirrorBadge(mirror("standby"), []), { label: "Mirror of github.com/acme/web", tone: "neutral", more: 0 });
61+ assert.equal(mirrorBadge(mirror("ci"), [])?.tone, "info");
62+ assert.equal(mirrorBadge(mirror("takeover"), [])?.label, "Taken over from github.com/acme/web");
63+ assert.equal(mirrorBadge(mirror("handing_back"), [])?.label, "Handing back to github.com/acme/web");
64+ assert.deepEqual(mirrorBadge(null, [follower("github.com/acme/web"), follower("git.example.com/web")]), {
65+ label: "Mirrored to github.com/acme/web",
66+ tone: "accent",
67+ more: 1,
68+ });
69+ assert.equal(mirrorBadge(null, []), null);
70+});
71+
72+test("a mirror standing by whose remote answers needs no banner", () => {
73+ assert.equal(mirrorBanner(mirror("standby"), [leader(true)]), null);
74+ assert.equal(mirrorBanner(mirror("standby"), []), null);
75+ assert.equal(mirrorBanner(mirror("standby"), [leader(false)]), "unreachable");
76+ assert.equal(mirrorBanner(mirror("ci"), [leader(false)]), "unreachable");
77+ assert.equal(mirrorBanner(mirror("ci"), [leader(true)]), "ci");
78+ assert.equal(mirrorBanner(mirror("takeover"), [leader(false)]), "takeover");
79+ assert.equal(mirrorBanner(mirror("handing_back"), []), "handing_back");
80+ assert.equal(mirrorBanner(null, [follower("x")]), null);
81+});
82+
83+test("hand-back decisions are read from their fields, and only known ones", () => {
84+ const form = new FormData();
85+ form.set("intent", "hand-back");
86+ form.set("decision:main", "keep_ours");
87+ form.set("decision:release/1.x", "pull_request");
88+ form.set("decision:dev", "delete_everything");
89+ assert.deepEqual(decisionsFrom(form.entries()), { main: "keep_ours", "release/1.x": "pull_request" });
90+});
91+
92+test("a hand-back is ready once the remote answers and every diverged branch has a decision", () => {
93+ const plan: HandbackPlan = {
94+ reachable: true,
95+ ready: false,
96+ refs: [
97+ { ref: "main", base: "a", ours: "b", theirs: "c", action: "diverged", decision: null },
98+ { ref: "dev", base: "a", ours: "b", theirs: "a", action: "push", decision: null },
99+ ],
100+ };
101+ assert.equal(handBackReady(plan, {}), false);
102+ assert.equal(handBackReady(plan, { main: "keep_theirs" }), true);
103+ assert.equal(handBackReady({ ...plan, reachable: false }, { main: "keep_theirs" }), false);
104+});
105+
106+test("settings come back from the form, keeping what it did not carry", () => {
107+ const leaderForm = new FormData();
108+ leaderForm.set("kind", "leader");
109+ leaderForm.set("notify", "inbox");
110+ leaderForm.set("autoTakeOver", "on");
111+ leaderForm.set("takeOverAfter", "2");
112+ leaderForm.set("githubWorkflows", "on");
113+ const current = { ...DEFAULT_MIRROR_SETTINGS, remotePushes: "overwrite" as const };
114+ assert.deepEqual(mirrorSettingsFrom(leaderForm, current), {
115+ notify: "inbox",
116+ takeOverAfter: 5,
117+ handBack: "ask",
118+ keepCiWarm: false,
119+ githubWorkflows: true,
120+ holdDeploys: false,
121+ remotePushes: "overwrite",
122+ });
123+ const followerForm = new FormData();
124+ followerForm.set("kind", "follower");
125+ followerForm.set("remotePushes", "overwrite");
126+ assert.deepEqual(mirrorSettingsFrom(followerForm, DEFAULT_MIRROR_SETTINGS), { ...DEFAULT_MIRROR_SETTINGS, remotePushes: "overwrite" });
127+});
128+
129+test("minutes and commits are shown within bounds", () => {
130+ assert.equal(clampMinutes(3), 5);
131+ assert.equal(clampMinutes(90.4), 90);
132+ assert.equal(clampMinutes(99999), 1440);
133+ assert.equal(clampMinutes(Number.NaN), 5);
134+ assert.equal(shortSha("0123456789abcdef"), "0123456");
135+ assert.equal(shortSha(null), "—");
136+});
+216−0
1+/**
2+ * Mirroring, as the site says it: the badge beside a repository's name,
3+ * the line across its pages, why a button is off on a read-only mirror,
4+ * and the settings and hand-back forms read back from what was posted.
5+ * The rules themselves (what a mirror refuses) are the integrations
6+ * service's; these only put them into words.
7+ */
8+
9+// Types only: the contracts' index does not load under `node --test`.
10+// mirror.test.ts holds the copies below to the contracts' own values.
11+import type { HandbackPlan, MirrorSettings, RefAction, RefDecision, RemoteBrief, RepoMirror } from "@g1t/contracts";
12+
13+/** As `mirrorWritable` in the contracts: it leads, or g1t has taken over. */
14+export function writable(mirror: RepoMirror | null | undefined): boolean {
15+ return !mirror || mirror.state === "takeover";
16+}
17+
18+/** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */
19+export const TAKE_OVER_MINUTES = [5, 24 * 60] as const;
20+
21+/** What a repository needs to say anything about its mirroring. */
22+export type MirroredRepo = { namespace: string; name: string; mirror?: RepoMirror | null };
23+
24+/**
25+ * A remote in brief, as the repository layout loads it for every page.
26+ * `syncedAt` when the service says when it last copied, for the line that
27+ * says how fresh g1t's copy is.
28+ */
29+export type MirrorBrief = RemoteBrief & { syncedAt?: string | null };
30+
31+/** Where the mirroring settings are, and the guide. */
32+export const MIRRORING_DOCS = "https://docs.g1t.sh/guides/mirroring/";
33+export const mirroringSettings = (base: string) => `${base}/settings/mirroring`;
34+
35+/**
36+ * Why pushing, merging, opening issues and pull requests, and assigning
37+ * agents are off: the repository follows a remote that leads. Null when
38+ * they are not off for that reason (it leads, or g1t has taken over).
39+ */
40+export function mirrorReason(repo: MirroredRepo | null | undefined): string | null {
41+ const mirror = repo?.mirror;
42+ if (!repo || writable(mirror)) return null;
43+ const full = `${repo.namespace}/${repo.name}`;
44+ if (mirror!.state === "handing_back") {
45+ return `${full} is handing back to ${mirror!.remote}; it takes changes again when that is done.`;
46+ }
47+ return `${full} is a mirror of ${mirror!.remote}. Work happens there until someone takes over in Settings → Mirroring.`;
48+}
49+
50+/**
51+ * Why running a workflow or running one again is off. In CI failover g1t
52+ * runs the remote's workflows, so they may be run here; a mirror standing
53+ * by, or handing back, runs nothing.
54+ */
55+export function workflowReason(repo: MirroredRepo | null | undefined): string | null {
56+ const state = repo?.mirror?.state;
57+ if (state === "ci" || state === "takeover") return null;
58+ return mirrorReason(repo);
59+}
60+
61+/** The leader a mirror follows, from the briefs. */
62+export function leaderOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief | null {
63+ return briefs?.find((brief) => brief.role === "leader") ?? null;
64+}
65+
66+/** The remotes that follow this repository, from the briefs. */
67+export function followersOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief[] {
68+ return (briefs ?? []).filter((brief) => brief.role === "follower");
69+}
70+
71+export type MirrorTone = "neutral" | "info" | "warn" | "accent";
72+
73+/** The badge beside the repository's name: what it is to its remotes, in a few words. */
74+export function mirrorBadge(
75+ mirror: RepoMirror | null | undefined,
76+ briefs: readonly MirrorBrief[] | null | undefined,
77+): { label: string; tone: MirrorTone; more: number } | null {
78+ if (mirror) {
79+ switch (mirror.state) {
80+ case "standby":
81+ return { label: `Mirror of ${mirror.remote}`, tone: "neutral", more: 0 };
82+ case "ci":
83+ return { label: "Mirror · running CI", tone: "info", more: 0 };
84+ case "takeover":
85+ return { label: `Taken over from ${mirror.remote}`, tone: "warn", more: 0 };
86+ case "handing_back":
87+ return { label: `Handing back to ${mirror.remote}`, tone: "warn", more: 0 };
88+ }
89+ }
90+ const followers = followersOf(briefs);
91+ if (followers.length === 0) return null;
92+ return { label: `Mirrored to ${followers[0]!.name}`, tone: "accent", more: followers.length - 1 };
93+}
94+
95+/**
96+ * Which line goes across the repository's pages, if any. A mirror standing
97+ * by whose remote answers needs none: the badge says it.
98+ */
99+export type MirrorBannerKind = "unreachable" | "ci" | "takeover" | "handing_back";
100+
101+export function mirrorBanner(
102+ mirror: RepoMirror | null | undefined,
103+ briefs: readonly MirrorBrief[] | null | undefined,
104+): MirrorBannerKind | null {
105+ if (!mirror) return null;
106+ const answering = leaderOf(briefs)?.reachable !== false;
107+ if ((mirror.state === "standby" || mirror.state === "ci") && !answering) return "unreachable";
108+ if (mirror.state === "standby") return null;
109+ return mirror.state;
110+}
111+
112+/** What happens to one branch when the takeover goes back. */
113+export function refActionWords(action: RefAction, remote: string): string {
114+ switch (action) {
115+ case "same":
116+ return "Already the same";
117+ case "push":
118+ return `Pushed to ${remote}`;
119+ case "fetch":
120+ return `Taken from ${remote}`;
121+ case "pull_request":
122+ return "Sent as a pull request (protected there)";
123+ case "diverged":
124+ return "Both moved: choose";
125+ }
126+}
127+
128+/** The choices for a branch both sides moved, in order. */
129+export function decisionChoices(remote: string): { value: RefDecision; label: string }[] {
130+ return [
131+ { value: "keep_ours", label: "Keep g1t's" },
132+ { value: "keep_theirs", label: `Keep ${remote}'s` },
133+ { value: "pull_request", label: "Send g1t's as a pull request" },
134+ ];
135+}
136+
137+/** A commit's short name, or a dash when the branch is not there. */
138+export function shortSha(sha: string | null | undefined): string {
139+ return sha ? sha.slice(0, 7) : "—";
140+}
141+
142+/** A branch name as a form field, for its decision. */
143+export const decisionField = (ref: string) => `decision:${ref}`;
144+
145+/** The decisions posted with a hand-back: one per branch both sides moved. */
146+export function decisionsFrom(entries: Iterable<[string, FormDataEntryValue]>): Record<string, RefDecision> {
147+ const decisions: Record<string, RefDecision> = {};
148+ for (const [name, value] of entries) {
149+ if (!name.startsWith("decision:")) continue;
150+ const decision = String(value);
151+ if (decision === "keep_ours" || decision === "keep_theirs" || decision === "pull_request") {
152+ decisions[name.slice("decision:".length)] = decision;
153+ }
154+ }
155+ return decisions;
156+}
157+
158+/**
159+ * Whether the hand-back can go: the remote answers, and every branch both
160+ * sides moved has a decision, the one chosen on the page or the plan's.
161+ */
162+export function handBackReady(plan: HandbackPlan, chosen: Record<string, RefDecision | undefined>): boolean {
163+ if (!plan.reachable) return false;
164+ return plan.refs.every((ref) => ref.action !== "diverged" || (chosen[ref.ref] ?? ref.decision) != null);
165+}
166+
167+/** Minutes before an automatic takeover, held to what the service takes. */
168+export function clampMinutes(value: number): number {
169+ const [least, most] = TAKE_OVER_MINUTES;
170+ if (!Number.isFinite(value)) return least;
171+ return Math.min(most, Math.max(least, Math.round(value)));
172+}
173+
174+type FormLike = { get(name: string): FormDataEntryValue | null };
175+
176+/**
177+ * A remote's settings from its form. The leader's form (a mirror's remote)
178+ * and a follower's carry different fields: what a form does not carry
179+ * keeps its value from `current`.
180+ */
181+export function mirrorSettingsFrom(form: FormLike, current: MirrorSettings): MirrorSettings {
182+ if (form.get("kind") === "follower") {
183+ const pushes = form.get("remotePushes");
184+ return { ...current, remotePushes: pushes === "overwrite" ? "overwrite" : pushes === "adopt" ? "adopt" : current.remotePushes };
185+ }
186+ const on = (name: string) => form.get(name) === "on";
187+ return {
188+ ...current,
189+ notify: form.get("notify") === "inbox" ? "inbox" : "banner",
190+ takeOverAfter: on("autoTakeOver") ? clampMinutes(Number(form.get("takeOverAfter") ?? "")) : null,
191+ handBack: on("handBackWhenClean") ? "when_clean" : "ask",
192+ keepCiWarm: on("keepCiWarm"),
193+ githubWorkflows: on("githubWorkflows"),
194+ holdDeploys: on("holdDeploys"),
195+ };
196+}
197+
198+/** Words for a remote's state, in its row. */
199+export function remoteStateWords(state: string): string {
200+ switch (state) {
201+ case "standby":
202+ return "Standing by";
203+ case "ci":
204+ return "CI failover";
205+ case "takeover":
206+ return "Taken over";
207+ case "handing_back":
208+ return "Handing back";
209+ case "following":
210+ return "Following";
211+ case "stuck":
212+ return "Stuck";
213+ default:
214+ return state;
215+ }
216+}
+3−0
88 contextClient,
99 deploymentsClient,
1010 memoryReviewClient,
11+ mirrorsClient,
1112 packagesClient,
1213 projectsClient,
1314 eventsClient,
5455 /** Each person's inbox, which the events service keeps. */
5556 export const inbox = inboxClient(EVENTS);
5657 export const integrations = integrationsClient(INTEGRATIONS);
58+/** Mirroring: a repository's remotes, takeovers and hand-backs, kept by integrations. */
59+export const mirrors = mirrorsClient(INTEGRATIONS);
5760 export const webhooks = webhooksClient(WEBHOOKS);
5861 export const actions = actionsClient(ACTIONS);
5962 export const deployments = deploymentsClient(DEPLOYMENTS);
+1−0
209209 route("plans/:id", "routes/repo/plan.tsx"),
210210 route("settings", "routes/repo/settings.tsx"),
211211 route("settings/repository", "routes/repo/settings-repository.tsx"),
212+ route("settings/mirroring", "routes/repo/settings-mirroring.tsx"),
212213 route("settings/access", "routes/repo/settings-access.tsx"),
213214 route("settings/keys", "routes/repo/settings-deploy-keys.tsx"),
214215 route("settings/branches", "routes/repo/settings-branches.tsx"),
+17−10
3838 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
3939 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
4040 import { Hint } from "../../components/ui/hint";
41+import { useWorkflowReason } from "../../components/mirror";
4142 import { searchLog } from "../../lib/log-lines";
4243 import { listArtifacts } from "../../lib/artifacts.server";
4344 import { expiresIn, formatBytes } from "../../lib/artifacts";
671672 const cancelling = live && run.conclusion === "cancelled";
672673 // Re-running is for the latest attempt of a finished run.
673674 const canRerun = member && !run.error && !live && latest;
675+ // A mirror standing by runs nothing; in CI failover it runs them again.
676+ const rerunBlocked = useWorkflowReason();
674677 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
675678 // Waiting on a person needs no quick refresh; a running job does.
676679 useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
730733 busy={busy}
731734 >
732735 {(open) => (
733− <Button type="button" variant="quiet" disabled={busy} onClick={open}>
734− <RotateCw size={13} />
735− Re-run failed jobs
736− </Button>
736+ <Hint label={rerunBlocked} disabled={rerunBlocked != null}>
737+ <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}>
738+ <RotateCw size={13} />
739+ Re-run failed jobs
740+ </Button>
741+ </Hint>
737742 )}
738743 </RerunDialog>
739744 )}
745750 busy={busy}
746751 >
747752 {(open) => (
748− <Button type="button" variant="quiet" disabled={busy} onClick={open}>
749− <RotateCw size={13} />
750− Re-run all jobs
751− </Button>
753+ <Hint label={rerunBlocked} disabled={rerunBlocked != null}>
754+ <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}>
755+ <RotateCw size={13} />
756+ Re-run all jobs
757+ </Button>
758+ </Hint>
752759 )}
753760 </RerunDialog>
754761 )}
885892 busy={busy}
886893 >
887894 {(open) => (
888− <Hint label="Re-run this job">
895+ <Hint label={rerunBlocked ?? "Re-run this job"} disabled={rerunBlocked != null}>
889896 <button
890897 type="button"
891898 aria-label={`Re-run ${selected.name}`}
892− disabled={busy}
899+ disabled={busy || rerunBlocked != null}
893900 onClick={open}
894901 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50"
895902 >
+10−4
99 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1010 import { AddCiPrompt } from "../../components/add-ci";
1111 import { Button, ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
12+import { useWorkflowReason } from "../../components/mirror";
13+import { Hint } from "../../components/ui/hint";
1214 import { CheckboxOption } from "../../components/ui/checkbox";
1315 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
1416 import { useAddresses } from "../../lib/addresses";
181183
182184 function RunWorkflow({ workflow }: { workflow: Workflow }) {
183185 const [open, setOpen] = useState(false);
186+ // A mirror standing by runs nothing; in CI failover it runs them.
187+ const blocked = useWorkflowReason();
184188 // Open, saying it is starting, until the run shows below or the error does.
185189 const running = usePending({ intent: "dispatch", workflow: workflow.id });
186190 const was = useRef(false);
192196 const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
193197 return (
194198 <div className="relative">
195− <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}>
196− <Play size={14} />
197− Run workflow
198− </Button>
199+ <Hint label={blocked} disabled={blocked != null}>
200+ <Button type="button" variant="quiet" disabled={blocked != null} onClick={() => setOpen((v) => !v)}>
201+ <Play size={14} />
202+ Run workflow
203+ </Button>
204+ </Hint>
199205 {open && (
200206 <Form
201207 method="post"
+14−6
88 import { page } from "../../lib/meta";
99 import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
1010 import { CheckboxOption } from "../../components/ui/checkbox";
11+import { Hint } from "../../components/ui/hint";
12+import { useMirrorReason } from "../../components/mirror";
1113 import { LabelChip } from "../../components/labels";
1214 import { integrations, work } from "../../lib/services.server";
1315 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
9597 const [params] = useSearchParams();
9698 const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null;
9799 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
100+ // A mirror takes no new issues until someone takes over.
101+ const mirrorBlocked = useMirrorReason();
98102 return (
99103 <div className="max-w-2xl">
100104 {names.length > 0 && (
113117 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
114118 </div>
115119 <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
116− <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…">
117− Import
118− </SubmitButton>
120+ <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
121+ <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…" disabled={mirrorBlocked != null}>
122+ Import
123+ </SubmitButton>
124+ </Hint>
119125 </div>
120126 {actionData && "importError" in actionData && (
121127 <div className="mt-2">
217223 </div>
218224 )}
219225 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
220− <SubmitButton name="intent" value="open" pending="Opening…">
221− Open issue
222− </SubmitButton>
226+ <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
227+ <SubmitButton name="intent" value="open" pending="Opening…" disabled={mirrorBlocked != null}>
228+ Open issue
229+ </SubmitButton>
230+ </Hint>
223231 </Form>
224232 </div>
225233 );
+11−5
4040 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
4141 import { accessTo, refusal, repoFor } from "../../lib/access.server";
4242 import { SubscriptionBox } from "../../components/notifications";
43+import { useMirrorReason } from "../../components/mirror";
44+import { Hint } from "../../components/ui/hint";
4345 import { useRefreshWhile } from "../../lib/refresh";
4446
4547
271273
272274 export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
273275 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
276+ // A mirror runs no agents until someone takes over.
277+ const mirrorBlocked = useMirrorReason();
274278
275279 // Follow agents at work without a manual reload.
276280 const running = pulls.some(
535539 {open && agentsEnabled && can.run && !assigned && !issue.queued && (
536540 <Form method="post" className="mt-3 space-y-2">
537541 <input type="hidden" name="action" value="run-hosted" />
538− <div className="*:w-full">
539− <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…">
540− <Sparkles size={14} />
541− Assign to g1t
542− </SubmitButton>
542+ <div className="*:w-full [&_button]:w-full">
543+ <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
544+ <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…" disabled={mirrorBlocked != null}>
545+ <Sparkles size={14} />
546+ Assign to g1t
547+ </SubmitButton>
548+ </Hint>
543549 </div>
544550 <details>
545551 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
+18−6
55
66 import type { Route } from "./+types/issues";
77 import { page } from "../../lib/meta";
8−import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
8+import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
99 import { Checkbox } from "../../components/ui/checkbox";
1010 import { Hint } from "../../components/ui/hint";
11+import { mirrorReason, type MirroredRepo } from "../../lib/mirror";
1112 import {
1213 Assignee,
1314 AssigneeStack,
105106 kept.delete("q");
106107
107108 // An archived repository's issues are locked: no new ones.
108− const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined;
109+ const layout = useRouteLoaderData("routes/repo/layout") as { repo?: MirroredRepo & { archivedAt?: string | null } } | undefined;
109110 const archived = Boolean(layout?.repo?.archivedAt);
111+ // A mirror takes no new issues until someone takes over: the button says why.
112+ const mirrorBlocked = mirrorReason(layout?.repo);
110113 // Handed over: the ticks are cleared. Refused: they stay, to try again.
111114 const form = useRef<HTMLFormElement>(null);
112115 useEffect(() => {
119122 state={state}
120123 query={kept.toString()}
121124 action={
122− archived ? undefined : (
125+ archived ? undefined : mirrorBlocked ? (
126+ <Hint label={mirrorBlocked} disabled>
127+ <Button type="button" disabled>
128+ <Plus size={15} />
129+ New issue
130+ </Button>
131+ </Hint>
132+ ) : (
123133 <ButtonLink to={`${base}/new`}>
124134 <Plus size={15} />
125135 New issue
184194 Tick the issues to hand over. g1t takes each one in a run of its own,
185195 and they all work at once.
186196 </p>
187− <SubmitButton variant="accent" pending="Starting sandboxes…">
188− Assign to g1t
189− </SubmitButton>
197+ <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
198+ <SubmitButton variant="accent" pending="Starting sandboxes…" disabled={mirrorBlocked != null}>
199+ Assign to g1t
200+ </SubmitButton>
201+ </Hint>
190202 {loaderData.computeNote && (
191203 <div className="basis-full">
192204 <ComputeNote note={loaderData.computeNote} />
+27−4
1616 import { PinButton } from "../../components/pin-button";
1717 import { StarButton } from "../../components/star-button";
1818 import { ArchivedBanner } from "../../components/repo-lifecycle";
19+import { MirrorBadge, MirrorBanner } from "../../components/mirror";
20+import type { MirrorBrief } from "../../lib/mirror";
1921 import { WelcomeBanner } from "../../components/welcome";
2022 import { clearWelcome, welcomes } from "../../lib/invites";
2123 import { notFound } from "../../lib/not-found.server";
2224 import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server";
2325 import { accessFor, countsFor, projectFor, repoFor } from "../../lib/access.server";
24−import { inbox, projects, repos } from "../../lib/services.server";
26+import { inbox, mirrors, projects, repos } from "../../lib/services.server";
2527 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
2628
2729 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
3335 const path = { namespace: params.owner, name: params.repo };
3436 // Members pin the workspace's projects, and what they open is their Recent.
3537 const member = viewer ? roleIn(viewer, params.owner) != null : false;
36− const [repo, counts, found, watching, shortcuts, stars] = await Promise.all([
38+ const [repo, counts, found, watching, shortcuts, stars, mirrorBriefs] = await Promise.all([
3739 repoFor(context, params),
3840 countsFor(context, params),
3941 projectFor(context, params),
4648 member ? projects.shortcuts(params.owner, viewer).catch(() => null) : null,
4749 // How many starred it and whether they did, for the header's Star button.
4850 repos.stars(path, viewer).then((found) => (found.ok ? found.value : null)).catch(() => null),
51+ // Its remotes in brief, for the badge and the line across its pages:
52+ // whether the one it mirrors answers, and those that follow it.
53+ repoFor(context, params).then((found) =>
54+ found.ok ? mirrors.briefs([found.value.id]).catch((): MirrorBrief[] => []) : ([] as MirrorBrief[]),
55+ ),
4956 ]);
5057 if (!repo.ok && !found.ok) {
5158 // Under a workspace's old name, after a rename: the project is at the new one.
8087 // Null for a pull request's working copy, or when it could not be read.
8188 stars: value.forkOf ? null : stars,
8289 signedIn: Boolean(viewer),
90+ mirrorBriefs: mirrorBriefs as MirrorBrief[],
8391 // Null when they cannot pin it: not one of their workspaces.
8492 pinned: member && project ? (shortcuts?.pinned ?? []).some((pinned) => pinned.id === project.id) : null,
8593 }, { headers });
101109 }
102110
103111 /** A repository's topics, each a way into Explore. */
104−function Header({ project, isPrivate, archived, namespace, name, description, large, actions }: {
112+function Header({ project, isPrivate, archived, namespace, name, description, large, actions, mirror }: {
105113 project: Project | null;
106114 isPrivate: boolean;
107115 namespace: string;
111119 large?: boolean;
112120 /** At the end of the row: Pin, Watch and Star. */
113121 actions?: ReactNode;
122+ /** Beside the visibility: what it is to its remotes. */
123+ mirror?: ReactNode;
114124 }) {
115125 const base = `/${namespace}/${name}`;
116126 return (
131141 </h1>
132142 <Pill>{isPrivate ? "private" : "public"}</Pill>
133143 {archived && <Pill>archived</Pill>}
144+ {mirror}
134145 {/* On a phone, on its own line under the name and the Watch menu. */}
135146 {description && <p className="order-last min-w-0 basis-full truncate text-sm text-muted sm:order-none sm:basis-0 sm:flex-1">{description}</p>}
136147 {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>}
177188 }
178189
179190 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
180− const { repo, project, member, access, welcome, watching, pinned, stars, signedIn } = loaderData;
191+ const { repo, project, member, access, welcome, watching, pinned, stars, signedIn, mirrorBriefs } = loaderData;
181192 const base = `/${repo.namespace}/${repo.name}`;
182193 // The project's own description, else the repository's as it is now.
183194 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
199210 name={repo.name}
200211 // The files' own About says it there, as the one place.
201212 description={filesPage ? null : description}
213+ mirror={<MirrorBadge mirror={repo.mirror} briefs={mirrorBriefs} />}
202214 actions={
203215 watching || stars || (project && pinned != null) ? (
204216 <>
233245 <ArchivedBanner base={base} owner={access.can.administer} settings={/^settings(\/|$)/.test(pathname.slice(base.length + 1))} />
234246 </div>
235247 )}
248+ {/* The mirroring settings say it themselves, in more detail. */}
249+ {!/^settings\/mirroring(\/|$)/.test(pathname.slice(base.length + 1)) && (
250+ <MirrorBanner
251+ base={base}
252+ full={`${repo.namespace}/${repo.name}`}
253+ mirror={repo.mirror}
254+ briefs={mirrorBriefs}
255+ admin={access.can.manage_integrations}
256+ className="mb-6"
257+ />
258+ )}
236259 <Outlet />
237260 </div>
238261 </>
+17−12
5252 import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
5353 import { ProductionShot } from "../../components/production-shot";
5454 import { GithubLinkStrip } from "../../components/github";
55+import { MirrorOverviewNote, useRepoMirror } from "../../components/mirror";
5556 import { distinctFacts } from "../../lib/memory-facts";
5657 import { githubApp } from "../../lib/github.server";
5758 import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
491492 const saved = await projects.update(user, params.owner, params.repo, projectChanges(form));
492493 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
493494 }
494− // Syncing from GitHub is pushing; stopping it is an integration; deploying is compute.
495− const refused = await refusal(context, params, intent === "github-sync" ? "push" : intent === "github-stop" ? "manage_integrations" : "run");
495+ // Deploying is compute. Syncing with GitHub is under Settings → Mirroring.
496+ const refused = await refusal(context, params, "run");
496497 if (refused) return { error: refused };
497− if (intent === "github-sync" || intent === "github-stop") {
498− const repo = await repos.get({ namespace: params.owner, name: params.repo }, user);
499− if (!repo.ok) return { error: repo.error.message };
500− const done =
501− intent === "github-sync" ? await githubApp.sync(user, repo.value.id) : await githubApp.unlinkRepo(user, repo.value.id);
502− return done.ok
503− ? { notice: intent === "github-sync" ? "Synced with GitHub." : "It is no longer kept in step with GitHub." }
504− : { error: done.error.message };
505− }
506498 const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null);
507499 return started.ok ? { notice: "Production is building." } : { error: started.error.message };
508500 }
791783 const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches, library, checks } =
792784 loaderData;
793785 const base = `/${params.owner}/${params.repo}`;
786+ // What it is to its remotes, from the repository layout.
787+ const { repo: mirrorRepo, briefs: mirrorBriefs, admin: mirrorAdmin } = useRepoMirror();
794788 const production = live.find((app) => app.kind === "production") ?? null;
795789 // The project's own domain, once it is active, is where production is visited.
796790 const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
819813
820814 return (
821815 <div className="space-y-8">
822− {loaderData.github && <GithubLinkStrip link={loaderData.github} />}
816+ {loaderData.github?.mode === "import" ? (
817+ <GithubLinkStrip link={loaderData.github} />
818+ ) : (
819+ <MirrorOverviewNote
820+ base={base}
821+ mirror={mirrorRepo?.mirror}
822+ briefs={mirrorBriefs}
823+ admin={mirrorAdmin}
824+ syncedAt={loaderData.github?.syncedAt}
825+ lastError={loaderData.github?.lastError}
826+ />
827+ )}
823828 {/* What the project is, running, and where its code is. */}
824829 <section className="overflow-hidden rounded-2xl border border-line bg-surface">
825830 {project && (
+9−1
55 import { page } from "../../lib/meta";
66 import { cloneUrl, useAddresses } from "../../lib/addresses";
77 import { Combobox } from "../../components/ui/combobox";
8+import { Hint } from "../../components/ui/hint";
9+import { useMirrorReason } from "../../components/mirror";
810 import {
911 SubmitButton,
1012 CopyLine,
7880 export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) {
7981 const { defaultBranch, base, bases, branches, selected, issue } = loaderData;
8082 const remote = cloneUrl(useAddresses(), `${params.owner}/${params.repo}`);
83+ // A mirror takes no pull requests until someone takes over.
84+ const mirrorBlocked = useMirrorReason();
8185
8286 if (branches.length === 0) {
8387 return (
140144 <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" />
141145 </Field>
142146 <ErrorText>{actionData?.error}</ErrorText>
143− <SubmitButton pending="Opening…">Open pull request</SubmitButton>
147+ <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
148+ <SubmitButton pending="Opening…" disabled={mirrorBlocked != null}>
149+ Open pull request
150+ </SubmitButton>
151+ </Hint>
144152 </Form>
145153 );
146154 }
+5−2
9696 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
9797 import { accessTo, refusal, repoFor } from "../../lib/access.server";
9898 import { SubscriptionBox } from "../../components/notifications";
99+import { useMirrorReason } from "../../components/mirror";
99100 import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
100101
101102 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
788789 const [bypassing, setBypassing] = useState(false);
789790 const rulesUnmet = (rules?.unmet ?? []).filter((violation) => violation.rule !== "required_status_checks");
790791 const rulesBypassable = rules?.bypassable ?? [];
792+ // A mirror takes no merges until someone takes over.
793+ const mirrorBlocked = useMirrorReason();
791794 // Why the merge button cannot be pressed, if it cannot.
792− const mergeBlocked = conflicting
795+ const mergeBlocked = mirrorBlocked ?? (conflicting
793796 ? "Resolve the conflicts first."
794797 : probing
795798 ? "Waiting to find out whether it merges cleanly."
803806 ? `Rules for ${defaultBranch} are not met. You may bypass them.`
804807 : ownersMissing
805808 ? "Code owners have to approve first."
806− : null;
809+ : null);
807810
808811 return (
809812 // The changes get the whole width; people and settings are a tab away.
+109−0
1+import type { MirrorAddInput, MirrorView, Result } from "@g1t/contracts";
2+
3+import type { Route } from "./+types/settings-mirroring";
4+import { type MirrorOutcome, MirroringSettings } from "../../components/mirror-settings";
5+import { RepoSettingsHeading } from "../../components/repo-settings-heading";
6+import { page } from "../../lib/meta";
7+import { decisionsFrom, mirrorSettingsFrom } from "../../lib/mirror";
8+import { refusal, requireInsider } from "../../lib/access.server";
9+import { mirrors, repos } from "../../lib/services.server";
10+import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
11+
12+export function meta({ params, ...args }: Route.MetaArgs) {
13+ return page(args, { title: `Mirroring · ${params.owner}/${params.repo} · g1t` });
14+}
15+
16+export async function loader({ params, context, request }: Route.LoaderArgs) {
17+ // Admins; to anyone without a role here the page does not exist.
18+ const { viewer, repo } = await requireInsider(context, params, "manage_integrations");
19+ const mirror = repo.mirror ?? null;
20+ // The hand-back plan asks the remote about every branch, so it is read
21+ // only when someone asks to review it, and only while g1t has taken over.
22+ const planRequested = new URL(request.url).searchParams.has("plan") && mirror?.state === "takeover";
23+ if (planRequested && viewer) {
24+ const planned = await mirrors.plan(viewer, repo.id);
25+ if (planned.ok) return { mirror, view: planned.value, planRequested, planError: null };
26+ return { mirror, view: unwrap(await mirrors.view(viewer, repo.id)), planRequested, planError: planned.error.message };
27+ }
28+ return { mirror, view: unwrap(await mirrors.view(viewer, repo.id)), planRequested, planError: null };
29+}
30+
31+export async function action({ request, params, context }: Route.ActionArgs): Promise<MirrorOutcome> {
32+ assertSameOrigin(request);
33+ const user = requireUser(context, request);
34+ const form = await request.formData();
35+ const intent = String(form.get("intent") ?? "");
36+ const failed = (error: string): MirrorOutcome => ({ intent, ok: false, message: null, error });
37+ const [refused, found] = await Promise.all([
38+ refusal(context, params, "manage_integrations"),
39+ repos.get({ namespace: params.owner, name: params.repo }, user),
40+ ]);
41+ if (refused) return failed(refused);
42+ if (!found.ok) return failed(found.error.message);
43+ const repoId = found.value.id;
44+ const done = (result: Result<MirrorView | unknown>, message: string | null): MirrorOutcome =>
45+ result.ok
46+ ? {
47+ intent,
48+ ok: true,
49+ message,
50+ error: null,
51+ notes: (result.value as Partial<MirrorView> | null)?.notes ?? [],
52+ }
53+ : failed(result.error.message);
54+ switch (intent) {
55+ case "take-over":
56+ return done(await mirrors.takeOver(user, repoId), "g1t leads now.");
57+ case "ci-on":
58+ return done(await mirrors.ci(user, repoId, true), "CI failover is on.");
59+ case "ci-off":
60+ return done(await mirrors.ci(user, repoId, false), "CI failover is off.");
61+ case "sync":
62+ return done(await mirrors.sync(user, repoId), "Synced.");
63+ case "hand-back":
64+ return done(await mirrors.handBack(user, repoId, decisionsFrom(form.entries())), "Handing back.");
65+ case "move-in":
66+ return done(await mirrors.moveIn(user, repoId, form.get("keepRemoteUpdated") === "on"), "Moved to g1t.");
67+ case "settings": {
68+ // The settings the form does not carry keep their values.
69+ const view = await mirrors.view(user, repoId);
70+ if (!view.ok) return failed(view.error.message);
71+ const remote = view.value.remotes.find((one) => one.id === String(form.get("remoteId") ?? ""));
72+ if (!remote) return failed("That remote is no longer linked to this repository.");
73+ return done(await mirrors.settings(user, remote.id, mirrorSettingsFrom(form, remote.settings)), "Saved.");
74+ }
75+ case "add": {
76+ const text = (name: string) => String(form.get(name) ?? "").trim() || null;
77+ const input: MirrorAddInput = {
78+ provider: form.get("provider") === "g1t" ? "g1t" : "git",
79+ role: form.get("role") === "leader" ? "leader" : "follower",
80+ url: text("url") ?? "",
81+ username: text("username"),
82+ token: text("token"),
83+ };
84+ const added = await mirrors.add(user, repoId, input);
85+ return added.ok ? { intent, ok: true, message: `Added ${added.value.name}.`, error: null } : failed(added.error.message);
86+ }
87+ case "remove":
88+ return done(await mirrors.remove(user, String(form.get("remoteId") ?? "")), "Removed.");
89+ }
90+ return failed("Unknown action.");
91+}
92+
93+export default function Mirroring({ loaderData, actionData, params }: Route.ComponentProps) {
94+ const base = `/${params.owner}/${params.repo}`;
95+ return (
96+ <>
97+ <RepoSettingsHeading base={base} />
98+ <MirroringSettings
99+ base={base}
100+ full={`${params.owner}/${params.repo}`}
101+ mirror={loaderData.mirror}
102+ view={loaderData.view}
103+ planRequested={loaderData.planRequested}
104+ planError={loaderData.planError}
105+ result={actionData}
106+ />
107+ </>
108+ );
109+}
+5−1
134134 </Link>
135135 </div>
136136 <p className="mt-3 text-xs text-faint">
137− Mirroring a repository from GitHub, GitLab or Bitbucket as a project's source is coming next.
137+ To keep it in step with a copy on another host, either way round, see{" "}
138+ <Link to={`${base}/settings/mirroring`} className="text-muted hover:text-fg">
139+ Mirroring
140+ </Link>
141+ .
138142 </p>
139143 </div>
140144 )}
+5−0
247247 </Link>
248248 <Badge>{repo.isPrivate ? "private" : "public"}</Badge>
249249 {repo.archivedAt && <Badge tone="warn">archived</Badge>}
250+ {repo.mirror && (
251+ <Badge tone={repo.mirror.state === "takeover" || repo.mirror.state === "handing_back" ? "warn" : "neutral"}>
252+ {repo.mirror.state === "takeover" ? "taken over" : repo.mirror.state === "handing_back" ? "handing back" : "mirror"}
253+ </Badge>
254+ )}
250255 </div>
251256 {repo.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{repo.description}</p>}
252257 <p className="mt-1 text-xs text-faint">
+3−0
413413 pub name: String,
414414 pub state: RemoteState,
415415 pub reachable: bool,
416+ /// When g1t last copied from it or pushed to it.
417+ #[serde(default)]
418+ pub synced_at: Option<String>,
416419 }
417420
418421 /// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's
+113−373
22
33 > **2026-10-08:** "bidirectional synchronization from GitHub or whatever
44 > providers … as well as setting up a different remote (think self-hosted g1t
5−> and mirroring to the hosted g1t.sh platform) so that it will keep remotes in
6−> sync with each other." And: "GitHub is down, I want to run GitHub's
7−> workflows on g1t, but only until GitHub is back up. But what if I push on
8−> g1t?" And: "It needs to be highly clear when a repository is mirrored, and
9−> things are disabled … It should still work as a repository when it's in
10−> mirror mode."
11−
12−This replaces the three loose modes shipped in Projects step 5 (import,
13−mirror, push, see PLAN.md "Projects") with one model that has a clear answer
14−for every push, wherever it lands.
15−
16−## What exists today
17−
18−- GitHub only, through the `g1t-sh` App. `github_repos.mode` is one of
19− `import | mirror | push` (integrations `0003_github.sql`).
20−- **Mirror** pulls on GitHub's push webhook and calls `mirror::copy` with
21− `Prune::Yes`, which *overwrites* g1t's refs and deletes g1t-only branches.
22− Anything pushed to g1t is silently lost at the next sync.
23−- **Push** ("Move to g1t") forwards each `git.push` to GitHub. A ref GitHub
24− refuses ends up in `last_error`, and nothing reconciles it.
25−- Mirrored and imported refs publish `git.push` with no actor. They start
26− `.g1t/workflows` like any push. They are written straight to the store, so
27− they skip `workflow_gate`.
28−- `.github/workflows` is never read, except as a reusable `uses:` target.
29−- No provider port. `Endpoint::github` and `https://github.com/{full_name}.git`
30− are hard-coded. `ProjectSource { kind: "mirror", provider }` is declared but
31− unused.
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."
3213
33−The first three behaviours are the bugs this plan fixes. Pushes are lost
34−silently, divergence goes undetected, and anyone who can push to GitHub can
35−change a workflow that runs with g1t's secrets.
14+The user-facing guide is `apps/docs/src/content/docs/guides/mirroring.md`.
15+This is the design record.
3616
3717 ## The rule
3818
39−**Every linked repository has exactly one leader at any moment. A write
40−reaches the leader first, or it doesn't land. The leader's word is final.**
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?":
4123
42−Everything else follows from that rule:
43−
44−- *Two-way sync* doesn't need its own mode. When g1t follows, a push to g1t is
45− forwarded to the leader *before* g1t's ref moves (write-through). When g1t
46− leads, a fast-forward push on the other side is adopted. Both sides accept
47− pushes, and there is always a tie-breaker.
48−- *Conflicts* can only happen when the leader was unreachable and someone
49− wrote anyway (failover), or when a follower took a write it couldn't
50− forward. A conflict is never resolved by overwriting silently.
51−- *Echo loops* are impossible. When an update's new sha equals the tip
52− already held, the update is a no-op. That covers a webhook announcing our
53− own push and a chain of g1t instances alike.
54−
55−## Words (UI and docs)
56−
57−| State | Badge on the repository | Meaning |
24+| State | Leader | On g1t |
5825 | --- | --- | --- |
59−| No link | (none) | An ordinary g1t repository. *Import* is a one-time copy that leaves no link. |
60−| **Mirror** | `Mirror of github.com/acme/web` | The remote leads and g1t follows. Pushes to g1t are forwarded to the remote. |
61−| **Mirrored** | `Mirrored to github.com/acme/web` (+ more) | g1t leads and the remotes follow. A repository can have any number of these. |
62−| **Failover** | `Mirror of github.com/acme/web · Failover` (amber) | The leader is unreachable, so g1t is temporarily accepting writes. They are replayed when the leader returns. |
63−| **Reconciling** | `… · 2 branches diverged` (red) | Some branches moved on both sides and need a decision. Other branches keep syncing. |
64−
65−A repository is a Mirror of **at most one** remote, and it can also be
66−Mirrored to others. Self-hosted g1t can be a Mirror of GitHub and Mirrored
67−to g1t.sh, for example. Creating a link that would make a cycle is refused.
68−For g1t-to-g1t links we can ask the other side for its leader chain.
69−
70−The existing modes map as follows: `mirror` → Mirror, `push` → Mirrored,
71−`import` → no link.
72−
73−## Pushes, case by case
74−
75−### When g1t is a Mirror (the remote leads)
76−
77−| Where the push happens | What happens |
78−| --- | --- |
79−| On the remote | The webhook arrives (or a poll runs when there is no webhook), g1t fetches, and the ref moves. A force-push by the leader is followed, because the leader may rewrite history. The old tip is kept at `refs/g1t/replaced/<branch>/<time>` for 30 days, and the change shows in the activity feed. Nothing is lost silently. |
80−| On g1t (person, agent, merge button, web edit) | **Write-through.** g1t receives the pack, pushes it to the leader with a compare-and-swap (expected old = our tip), and moves its own ref only when the leader accepts. If the leader refuses (branch protection, non-fast-forward, a hook), the push fails with the leader's own message passed through as `remote:` lines. Branch protection on the remote is enforced for free. |
81−| On g1t, branch only on g1t | There are no g1t-only branches on a Mirror. Every branch is forwarded. This removes the current `Prune::Yes` data loss: g1t never holds anything the leader doesn't. |
82−| On g1t, leader unreachable | Depends on the failover setting (below). The default refuses with: `acme/web is a mirror of github.com/acme/web, which isn't answering. Pushes resume when it's back, or turn on failover in Settings → Mirroring.` |
83−
84−The cost is latency: a push to a Mirror takes as long as a push to the
85−remote plus our own write. That trade buys g1t's commits never diverging
86−while the leader is up. The 40 MB pack relay limit in `mirror.rs` applies to
87−forwarded pushes as well, and the error message has to say so.
88−
89−### When g1t is Mirrored (g1t leads)
90−
91−| Where the push happens | What happens |
92−| --- | --- |
93−| On g1t | It's g1t's normal push with all of g1t's rules. After it lands, a `git.push` event queues a forward to each follower (this exists today). A follower that refuses (its own protection, for example) marks that ref **stuck** on that remote and shows it on the repository. Retries back off and never force. |
94−| On the remote, fast-forward | **Default: adopt.** The webhook triggers a fetch, and the update goes through g1t's ref rules as if the pusher had pushed to g1t, with the pusher mapped to a g1t account (see Actors). If g1t's rules allow it, the ref moves and the update is forwarded to the other followers. If they refuse (a protected branch, say), the ref is marked **diverged**. |
95−| On the remote, not a fast-forward | Marked **diverged**. g1t never force-pushes over it without a person's or agent's decision. |
96−
97−A setting covers pushes made directly on the remote:
98−
99−- **Adopt fast-forwards** (default)
100−- **Overwrite them**: g1t force-pushes its tip and keeps the replaced
101− remote tip under `refs/g1t/replaced/…`. This suits teams that consider the
102− remote read-only.
103−- **Lock the remote**: g1t creates a ruleset on the GitHub repository that
104− lets only the g1t App update refs. This needs `administration: write` on
105− the App, which we request only when someone picks this option.
106−
107−### Diverged branches
108−
109−A diverged branch stops syncing and every other branch keeps going. Both
110−tips are kept: g1t's at the branch, and the other side's at
111−`refs/remotes/<remote>/<branch>`, which is browsable and diffable. The
112−repository shows `main diverged from github.com/acme/web: 3 commits here, 1
113−there` with three actions:
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`. |
11432
115−1. **Keep g1t's**, which force-pushes to the other side (the old tip is kept).
116−2. **Keep theirs**, which moves g1t's ref (the old tip is kept).
117−3. **Merge them**, which opens a pull request merging the other tip into the
118− branch. One click hands it to @g1t, and it lands through the normal rules.
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.
11936
120−## Failover: GitHub is down, and I push on g1t
37+## Decisions taken
12138
122−Failover is a *state* of a Mirror, not a separate mode. The setting
123−"When github.com/acme/web is unreachable" has three choices:
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.
12457
125−- **Stop accepting pushes** (default for new links)
126−- **Ask me**: a banner and an inbox item for repository admins offer
127− **Start failover**.
128−- **Fail over automatically**
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.
12974
130−**Entering failover.** The leader is unreachable when forwarded writes fail
131−with a timeout, a connection error or a 5xx, *and* a health probe of
132−`info/refs` fails 3 times over 2 minutes. A provider status page alone isn't
133−enough, because they lag and over-report. While in failover:
75+## Where it lives
13476
135−- g1t accepts pushes, merges and agent work on every branch. Branch rules
136− still apply: g1t imports a read-only copy of the remote's protection when
137− the link is made, refreshes it on every sync, and enforces it during
138− failover. "Main needs a reviewed pull request" still holds while GitHub is
139− down.
140−- Each ref records its **base**, the last sha both sides agreed on.
141−- The repository wears the amber Failover badge with the duration and the
142− count of commits waiting to go back.
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 0017 `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 0006 `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**.
143112
144−**Leaving failover.** Once the probe succeeds 3 times over 5 minutes, each
145−branch changed during failover is replayed:
113+## Not yet
146114
147−| Remote since base | g1t since base | Result |
148−| --- | --- | --- |
149−| unchanged | moved | Fast-forward push to the remote. |
150−| moved | unchanged | Fetch, as normal. |
151−| moved | moved, one contains the other | Fast-forward whichever side is behind. |
152−| moved | moved, split | **Diverged** (above). |
153−| (remote refuses: protected branch) | moved | **Replayed as a pull request on GitHub** from `g1t/failover/<branch>`, titled *Changes made on g1t while GitHub was unreachable*. g1t can't push straight to a protected main, and shouldn't. |
154−
155−The repository goes back to a plain Mirror when nothing is diverged or
156−stuck. Until then it shows Reconciling. An admin can end failover by hand at
157−any time, and the replay runs the same way.
158−
159−## Workflows
160−
161−### Which files run on g1t
162−
163−| Files | Default | Setting |
164−| --- | --- | --- |
165−| `.g1t/workflows` | Run on every push, whatever its origin | None needed. These are g1t's files: having them means you want them run. Workflows can filter on `g1t.event.origin` (`local` / `remote`). |
166−| `.github/workflows` | **Off** | **Run GitHub's workflows on g1t: Off · While GitHub Actions is down · Always.** |
167−
168−This deliberately narrows the rule in `crates/actions/src/workflow.rs` ("g1t
169−never reads `.github`"). g1t reads `.github/workflows` only for repositories
170−linked to GitHub, and only when this setting is on. When both folders define
171−the same `name:`, `.g1t` wins.
172−
173−### "While GitHub Actions is down"
174−
175−This is the scenario from the request. The window opens when either of these
176−happens:
177−
178−1. A pushed commit gets no check suite on GitHub within 10 minutes. This
179− needs `checks: read` on the App and is the most reliable signal, because it
180− means GitHub really didn't run it.
181−2. Someone with admin on the repository clicks **GitHub Actions is down: run
182− here** on the repository or on a single commit. A per-commit **Run on g1t**
183− button is always available, in any setting.
184−
185−GitHub's status page is shown next to the banner for context. It never opens
186−or closes the window by itself.
187−
188−The window closes once GitHub starts check suites again for new commits. Runs
189−already started on g1t finish.
190−
191−While the window is open:
192−
193−- Pushes and pull requests on g1t, **including failover pushes**, run the
194− `.github` workflows that match.
195−- Commits pushed during the window that GitHub never ran are **backfilled**:
196− g1t offers to run them in one click.
197−- Results go back to GitHub as check runs named `g1t / <workflow> /
198− <job>` (needs `checks: write`). People on GitHub see them, and GitHub's
199− required-check rules can name them if the team chooses.
200−
201−**Deploys and other side effects.** A workflow running in two places can
202−deploy twice. In "While down" mode, jobs that declare an `environment:` or
203−use a secret named `*DEPLOY*`/`*TOKEN*` that only GitHub has are **held for
204−approval** by default. The setting is "Side-effect jobs in GitHub's
205−workflows: hold for approval (default) · run". In "Always" mode they run.
206−That mode is for teams that have moved CI to g1t and keep GitHub for code
207−review.
208−
209−**Secrets.** GitHub won't give out secret values. When the setting is turned
210−on, g1t lists every `secrets.X` the `.github` workflows reference, ticks off
211−the ones already set on the project, and blocks "Always" until each is set
212−or marked "not needed". A run that hits a missing secret fails with
213−`NPM_TOKEN is set on GitHub but not on g1t: add it in Project → Secrets`.
214−It does not fail with an empty string.
215−
216−**After GitHub returns.** Failover commits replayed to GitHub start GitHub's
217−own workflows there. That is expected, because GitHub's checks are what
218−GitHub's rules ask for. g1t's check runs for the same sha are already posted,
219−so reviewers see both. Deploy jobs held on g1t can be discarded once GitHub
220−has deployed.
221−
222−### Workflow changes that arrive from a remote
223−
224−Today a commit fetched from GitHub that edits `.g1t/workflows` runs with
225−g1t's secrets, and its author never needed `workflow_files: write` on g1t.
226−That gets fixed in step 1, before any of the rest:
227−
228−- A fetched push that changes workflow files, or `.github/workflows` while
229− that setting is on, runs only if the pusher maps to a g1t account with
230− `workflow_files: write` on the repository.
231−- Otherwise its runs wait for approval: `Workflow changed on GitHub by
232− @octo, who has no write access to workflows here. Approve run?` This is
233− the same mechanism as runs from forks.
234−
235−## Actors
236−
237−GitHub's push webhook names the pusher, and `github_accounts` already links
238−GitHub users to g1t users. A fetched push is attributed to the linked g1t
239−account, or to `github:<login>` (shown greyed out, not a g1t user) when there
240−is none. Today such pushes have no actor. Webhooks, audit and the workflow
241−`actor` all get the attributed value.
242−
243−## What works on a Mirror, and what's disabled
244−
245−This table is also the source for the UI's disabled states. Every disabled
246−control uses the shadcn Tooltip with the reason and the setting that would
247−enable it.
248−
249−| | Mirror (remote leads) | Mirror in Failover | Mirrored (g1t leads) |
250−| --- | --- | --- | --- |
251−| Browse, clone, fetch, blame, search | ✓ | ✓ | ✓ |
252−| Push branches and tags | ✓ forwarded to the remote | ✓ held, replayed later | ✓ |
253−| Branch protection | The remote's (read-only copy shown) | The remote's copy, enforced by g1t | g1t's |
254−| Pull requests | **The remote's.** Listed on g1t; *New pull request* and agents open them on the remote; *Merge* merges there through the API under its rules | g1t pull requests allowed. Each is replayed as a remote pull request when the remote returns | g1t's |
255−| Merge queue, required g1t checks | Disabled: "Merges happen on GitHub, which leads this repository." | ✓ on g1t's copy of the rules | ✓ |
256−| Issues, agents, plans | ✓ g1t's own. Agents push branches that are forwarded, and open pull requests on the remote | ✓ | ✓ |
257−| `.g1t/workflows` | ✓ | ✓ | ✓ |
258−| `.github/workflows` | Per setting | Per setting | Per setting (only if a follower is GitHub) |
259−| Projects, deployments, previews, secrets | ✓ (the on-ramp) | ✓ | ✓ |
260−| Releases | Read from the remote (later) | Held | g1t's, copied to followers (later) |
261−| Rename, transfer | g1t side only. The link stays | Same | Same |
262−| Delete branch, set default branch | Forwarded / read from the remote | Held | g1t's, forwarded |
263−| Archive | Stops syncing. The link is kept and paused | Not allowed | Stops forwarding |
264−| Unlink | Becomes an ordinary repository (choice: keep g1t-side failover commits) | Must reconcile first | Followers stop receiving |
265−
266−Other places that must show the state, not only the badge:
267−
268−- **Clone box:** `This is a mirror of github.com/acme/web. Pushes here are
269− forwarded there.`
270−- **Push output:** `remote: forwarded to github.com/acme/web (412 ms)`, so
271− people learn the model the first time they push.
272−- **Repository header:** the sync dot (`in sync · 40s ago`, `syncing`,
273− `failover · 23m`, `2 diverged`, `stuck on gitlab.com/…`). It opens
274− **Settings → Mirroring**, which lists every remote, each branch's state,
275− the last 50 sync events and **Sync now**.
276−- **Workspace and project lists:** a small mirror glyph with the leader's
277− host.
278−
279−## The provider port
280−
281−All of this lives behind one port, so GitLab, Bitbucket, plain git and other
282−g1t instances are each an adapter. The Rust trait lives in
283−`services/integrations`, because the connections live there:
284−
285−```rust
286−trait Remote {
287− fn kind(&self) -> RemoteKind; // github | g1t | gitlab | bitbucket | git
288− fn capabilities(&self) -> Capabilities; // webhooks, checks, lock, pull_requests, protection
289− async fn endpoint(&self) -> Result<mirror::Endpoint>;// URL + fresh credential for mirror.rs
290− async fn parse_event(&self, req: &Request) -> Result<Vec<RemoteRefChange>>; // verify + normalise
291− async fn probe(&self) -> Health; // info/refs reachability
292− async fn protection(&self) -> Result<Vec<RuleCopy>>; // read the remote's branch rules
293− async fn lock(&self, on: bool) -> Result<()>; // optional
294− async fn post_check(&self, sha: &str, run: &CheckRun) -> Result<()>; // optional
295− async fn pull_requests(&self) -> Result<PullRequestPort>; // optional, step 7
296−}
297−```
298−
299−- Each `Capabilities` flag that's off greys out the matching UI and setting.
300− The UI never offers a lever the provider can't honour.
301−- Providers without webhooks (plain `git`, or a self-hosted g1t behind a
302− firewall) get a **poll** every 1 to 10 minutes with backoff, using
303− `ls-remote` and comparing tips. The poll uses the same code path as the
304− webhook.
305−- **`g1t` adapter (self-hosted ↔ g1t.sh).** It authenticates with a
306− fine-grained token on the other instance (`contents: write`,
307− `webhooks: write`) and registers its own webhook there through our API.
308− Because we control both ends, write-through works in either direction, and
309− the cycle check can ask the other side for its chain. Issues and pull
310− requests between g1t instances come later, over the public API.
311−
312−## Data
313−
314−**integrations, `remotes`** (replaces `github_repos`; existing rows are
315−migrated):
316−- `id`, `repo_id`, `workspace`, `kind`, `url`, `role` (`leader` |
317− `follower`), and `connection_id` (installation or token row).
318−- `provider_ref`, provider-specific JSON (GitHub: `installation_id`,
319− `github_repo_id`).
320−- `settings` JSON: on-unreachable, remote-push policy, `.github` workflows,
321− side-effect jobs.
322−- `state` (`ok` | `failover` | `reconciling` | `paused` | `error`),
323− `state_since`, `last_error`, `synced_at`.
324−- A unique index on `(repo_id) WHERE role = 'leader'` enforces at most one
325− leader.
326−
327−**repos, `ref_sync`** (per repository, inside the repository's store so it
328−moves atomically with refs):
329−- `remote_id`, `ref`, `base_sha` (last agreed), `remote_sha` (last seen
330− there).
331−- `state` (`ok` | `ahead` | `behind` | `held` | `diverged` | `stuck`),
332− `updated_at`.
333−
334−**repos, repository row:** `mirror_of` (remote id or null), cached from
335−integrations through a `remote.updated` event. The git front door, commit
336−API and merge paths can then decide forward-or-refuse without an RPC.
337−`lifecycle::archived_refusal` is the template for a matching
338−`mirror::route(repo, ref)` used at the same call sites.
339−
340−**contracts, `GitPush`** gains `origin: { kind: "local" | "remote",
341−remote_id?, provider?, pusher? }`. Actions, webhooks, audit and the inbox
342−read it. `ProjectSource`'s unused `mirror` variant is dropped: a Mirror is
343−still a hosted repository with a full copy, so its project stays `hosted`, as
344−PLAN.md already decided.
345−
346−**Events:** `remote.linked`, `remote.updated`, `remote.unlinked`,
347−`remote.failover_started`, `remote.failover_ended`, `ref.diverged`,
348−`ref.reconciled`. These are in the public webhook catalogue, so people can
349−build alerts on them.
350−
351−## Build order
352−
353−1. **Safety and the port.** The `Remote` trait with the GitHub adapter. Move
354− `github_repos` to `remotes`. Add `ref_sync` with base shas. Add `origin`
355− and the attributed actor on `GitPush`. Echo suppression by sha. Keep
356− replaced tips instead of overwriting silently. Approval for workflow
357− changes arriving from a remote. Badge, sync dot, and **Settings →
358− Mirroring** (read-only state).
359−2. **Write-through Mirror.** Forward pushes from the git front door, the
360− commit API, merges and agents. Pass the leader's refusals through. Apply
361− the disabled-feature table in the UI. Read-only copy of the remote's
362− protection.
363−3. **Mirrored with remote pushes handled.** Adopt / overwrite / lock, stuck
364− refs, diverged refs with the three resolutions.
365−4. **Failover.** Probe, ask/automatic, enforcement of the protection copy,
366− replay with the table above, pull-request replay for protected branches.
367−5. **GitHub's workflows on g1t.** The three-way setting, the
368− missing-check-suite signal, *Run on g1t*, backfill, check runs back to
369− GitHub, held side-effect jobs, the secrets checklist.
370−6. **g1t ↔ g1t.** The `g1t` adapter, polling, the cycle check, docs for
371− self-hosted ↔ g1t.sh.
372−7. **Remote pull requests on Mirrors.** List, open, merge through the API,
373− agents opening theirs there (already "Not yet" in PLAN.md step 5).
374−8. **GitLab, Bitbucket, plain git** adapters.
375−
376−Each step updates `guides/github.md` and a new `guides/mirroring.md` in the
377−same change (docs standard). `guides/github.md`'s "anything pushed to the
378−g1t copy directly is overwritten" goes away with step 1.
379−
380−## Decisions to confirm
381−
382−1. **Failover defaults to "Stop accepting pushes"** for new links, with
383− "Ask me" one click away. Automatic failover is opt-in, because it creates
384− work that has to be replayed.
385−2. **On a Mirror, pull requests live on the leader**, and g1t has no pull
386− requests of its own there (except during failover). One place to merge
387− avoids two review histories for one branch. Making g1t the leader is the
388− way to get g1t's review and queue.
389−3. **App permissions are asked for when a feature needs them**:
390− `checks: read/write` for step 5 and `administration: write` only for
391− *Lock the remote*. They are not requested up front.
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.
+6−0
11751175 integrations' `github_repos`. Not yet: previews and statuses on GitHub's own
11761176 pull requests, comments and pull requests copied, GitLab and Bitbucket.
11771177
1178+**Mirroring, built 2026-10-08:** a mirror stands by as a read-only copy of
1179+the remote that leads, until someone takes over on g1t, then hands back ref
1180+by ref or moves it to g1t for good; CI failover runs the remote's
1181+workflows on g1t. Links to GitHub, another g1t or any git host. Design and
1182+what is not yet: [MIRRORING.md](MIRRORING.md).
1183+
11781184 ### People and search
11791185
11801186 > **2026-10-04:** "pull up user profiles, using a /u/username prefix kinda
+2−0
119119 name: string;
120120 state: RemoteState;
121121 reachable: boolean;
122+ /** When g1t last copied from it or pushed to it. */
123+ syncedAt?: string | null;
122124 };
123125
124126 export type MirrorAddInput = {
+1−0
535535 name: row.name.clone(),
536536 state: row.state(),
537537 reachable: hosts.get(&row.host()).is_none_or(|host| host.unreachable_since.is_none()),
538+ synced_at: row.synced_at.clone(),
538539 })
539540 .collect())
540541 }