Skip to content

Commit

Merge branch 'mirroring' into artifacts-mode

syntaqxcommitted Parentsb4aff74e28dd1cBrowse files
81 files+2206−580/81 viewed
+1−0
1616 mod deploy_keys;
1717 mod limits;
1818 mod logs;
19+mod mirrors;
1920 mod mcp;
2021 mod notifications;
2122 mod oauth;
+372−0
1+//! Mirroring over REST and MCP: a repository's links to copies of it on
2+//! other hosts (see `g1t_contracts::mirrors`), at
3+//! `/repos/{owner}/{name}/mirror…`, and as the MCP `mirror` tool.
4+//!
5+//! The integrations service keeps the links and decides who may change
6+//! them: the Admin role on the repository; syncing needs push. Agents never
7+//! move a repository to g1t or handle a remote's token.
8+
9+use g1t_contracts::mirrors::{
10+ MirrorActArgs, MirrorAddArgs, MirrorCiArgs, MirrorHandBackArgs, MirrorMoveInArgs, MirrorRemoveArgs, MirrorSettings,
11+ MirrorSettingsArgs, MirrorViewArgs, RefDecision, RemoteProvider, RemoteRole,
12+};
13+use g1t_contracts::repos::{GetArgs, Repo};
14+use g1t_contracts::{FailureCode, Outcome, Viewer};
15+use serde_json::{Value, json};
16+use std::collections::BTreeMap;
17+use worker::Result;
18+
19+use crate::operations::{Services, repo_path};
20+
21+/// One operation on a repository's mirroring.
22+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23+pub enum MirrorsOp {
24+ GetMirror,
25+ GetHandBackPlan,
26+ TakeOver,
27+ SetCiFailover,
28+ HandBack,
29+ MoveToG1t,
30+ SyncMirror,
31+ AddRemote,
32+ UpdateRemote,
33+ RemoveRemote,
34+}
35+
36+impl MirrorsOp {
37+ /// Every one: `Op::ALL` lists each as `Op::Mirrors(…)`, which a test
38+ /// checks against this.
39+ #[cfg(test)]
40+ pub const ALL: [MirrorsOp; 10] = [
41+ MirrorsOp::GetMirror,
42+ MirrorsOp::GetHandBackPlan,
43+ MirrorsOp::TakeOver,
44+ MirrorsOp::SetCiFailover,
45+ MirrorsOp::HandBack,
46+ MirrorsOp::MoveToG1t,
47+ MirrorsOp::SyncMirror,
48+ MirrorsOp::AddRemote,
49+ MirrorsOp::UpdateRemote,
50+ MirrorsOp::RemoveRemote,
51+ ];
52+
53+ pub fn name(self) -> &'static str {
54+ match self {
55+ MirrorsOp::GetMirror => "get_mirror",
56+ MirrorsOp::GetHandBackPlan => "get_hand_back_plan",
57+ MirrorsOp::TakeOver => "take_over_mirror",
58+ MirrorsOp::SetCiFailover => "set_ci_failover",
59+ MirrorsOp::HandBack => "hand_back_mirror",
60+ MirrorsOp::MoveToG1t => "move_mirror_to_g1t",
61+ MirrorsOp::SyncMirror => "sync_mirror",
62+ MirrorsOp::AddRemote => "add_mirror_remote",
63+ MirrorsOp::UpdateRemote => "update_mirror_remote",
64+ MirrorsOp::RemoveRemote => "remove_mirror_remote",
65+ }
66+ }
67+
68+ /// For the API reference.
69+ pub fn title(self) -> &'static str {
70+ match self {
71+ MirrorsOp::GetMirror => "Get a repository's mirroring",
72+ MirrorsOp::GetHandBackPlan => "Get the hand-back plan",
73+ MirrorsOp::TakeOver => "Take over a mirror",
74+ MirrorsOp::SetCiFailover => "Start or end CI failover",
75+ MirrorsOp::HandBack => "Hand a takeover back",
76+ MirrorsOp::MoveToG1t => "Move a mirror to g1t",
77+ MirrorsOp::SyncMirror => "Sync a repository's remotes",
78+ MirrorsOp::AddRemote => "Add a remote",
79+ MirrorsOp::UpdateRemote => "Update a remote's settings",
80+ MirrorsOp::RemoveRemote => "Remove a remote",
81+ }
82+ }
83+
84+ pub fn description(self) -> &'static str {
85+ match self {
86+ MirrorsOp::GetMirror => "A repository's links to other hosts. `remotes` lists each with its `role` (`leader`: the remote leads and this repository is its mirror; `follower`: g1t leads and the remote is kept in step), `provider` (`github`, `g1t` or `git`), `name` (`github.com/acme/web`), `state` (a mirror is `standby`, `ci`, `takeover` or `handing_back`; a follower is `following` or `stuck`), `reachable` and `unreachable_since`, `synced_at`, `last_error` and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.",
87+ MirrorsOp::GetHandBackPlan => "What handing a takeover back would do, ref by ref, in `plan.refs`: each with `base` (where it was when the takeover began), `ours`, `theirs` and `action`: `push` (only g1t moved), `fetch` (only the remote moved), `pull_request` (the remote protects the branch: g1t's commits go to `g1t/handback/<branch>` there as a pull request, and g1t follows the remote's branch) or `diverged` (both moved: it needs a decision). `plan.reachable` is false while the remote does not answer; `plan.ready` once it answers and every diverged ref has a decision. Asks the remote, so it takes a moment. Needs the Admin role.",
88+ MirrorsOp::TakeOver => "Take over a mirror: g1t leads it for now, so pushes, pull requests, issues, agents and workflows work on g1t, whether or not the remote is answering. Each ref's commit is recorded as where the takeover began. Workflows that deploy wait for approval, unless the link's settings say otherwise. End it with hand_back_mirror, or keep it with move_mirror_to_g1t. Needs the Admin role.",
89+ MirrorsOp::SetCiFailover => "Start (`on: true`) or end (`on: false`) CI failover on a mirror standing by: the remote keeps the code, and g1t runs its workflows, `.github/workflows` as well as `.g1t/workflows`, on each push copied in. Workflows that deploy wait for approval unless the link's settings say otherwise. Needs the Admin role.",
90+ MirrorsOp::HandBack => "Hand a takeover back to the remote, as get_hand_back_plan describes, with `decisions` for diverged refs: an object from ref (`refs/heads/docs`) to `keep_ours` (g1t's commit is pushed over the remote's), `keep_theirs` (the remote's is taken; g1t's is kept under `refs/g1t/replaced/`) or `pull_request`. Refused while the remote does not answer or a diverged ref has no decision. The repository is read-only while it goes back; if anything is refused, g1t keeps the lead and says why. On success the mirror stands by again, and `notes` lists the pull requests opened. Needs the Admin role.",
91+ MirrorsOp::MoveToG1t => "Move a mirror to g1t for good: it stops being a mirror and g1t no longer tracks the remote, so pushes made there no longer come here. Allowed while it stands by, in CI failover, or during a takeover, whose work stays as it is. With `keep_remote_updated: true` the remote becomes a follower instead of being unlinked, and g1t pushes to it from then on. Needs the Admin role; agents' tokens are refused.",
92+ MirrorsOp::SyncMirror => "Bring a repository's remotes in step now: a mirror standing by or in CI failover copies the remote's branches and tags in; each follower is pushed g1t's (which also clears a follower that was stuck by pushing over what changed there). Needs push access.",
93+ MirrorsOp::AddRemote => "Link a repository to a remote on another g1t or any git host over HTTPS (GitHub repositories are linked by importing them through g1t's GitHub App). `role` `follower`: g1t leads and pushes to it. `role` `leader`: this repository becomes its mirror; only an empty repository can, and it is filled from the remote. `url` is its https clone address, `token` a token that can read and push (kept sealed, never returned), `username` the user it is sent as. Needs the Admin role; agents' tokens are refused.",
94+ MirrorsOp::UpdateRemote => "Change a remote's `settings`, all optional: `notify` (`banner`, or `inbox` to also tell the workspace's owners), `take_over_after` (minutes, 5 to 1440, after which g1t takes over on its own while the remote does not answer; null for never), `hand_back` (`when_clean`: an automatic takeover goes back on its own once every ref goes back without a decision; `ask`), `keep_ci_warm` (run `.g1t/workflows` on pushes copied in while standing by), `github_workflows` (run `.github/workflows` in CI failover and takeovers), `hold_deploys`, and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.",
95+ MirrorsOp::RemoveRemote => "Unlink a remote. A mirror becomes an ordinary repository with what it has; a follower is no longer pushed to. Refused during a takeover: hand it back or move it to g1t first. Needs the Admin role; agents' tokens are refused.",
96+ }
97+ }
98+
99+ pub fn input(self) -> Value {
100+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
101+ let id = json!({ "type": "string", "description": "The remote's id (rmt_…), from get_mirror." });
102+ let (properties, required): (Value, &[&str]) = match self {
103+ MirrorsOp::GetMirror | MirrorsOp::GetHandBackPlan | MirrorsOp::TakeOver | MirrorsOp::SyncMirror => {
104+ (json!({ "repo": repo }), &["repo"])
105+ }
106+ MirrorsOp::SetCiFailover => (
107+ json!({ "repo": repo, "on": { "type": "boolean", "description": "True to start CI failover, false to end it." } }),
108+ &["repo", "on"],
109+ ),
110+ MirrorsOp::HandBack => (
111+ json!({
112+ "repo": repo,
113+ "decisions": {
114+ "type": "object",
115+ "description": "For each diverged ref, by its full name: keep_ours, keep_theirs or pull_request.",
116+ "additionalProperties": { "type": "string", "enum": ["keep_ours", "keep_theirs", "pull_request"] },
117+ },
118+ }),
119+ &["repo"],
120+ ),
121+ MirrorsOp::MoveToG1t => (
122+ json!({
123+ "repo": repo,
124+ "keep_remote_updated": { "type": "boolean", "description": "True to keep pushing to the remote from g1t; false (the default) to stop tracking it." },
125+ }),
126+ &["repo"],
127+ ),
128+ MirrorsOp::AddRemote => (
129+ json!({
130+ "repo": repo,
131+ "provider": { "type": "string", "enum": ["g1t", "git"], "description": "Another g1t (g1t.sh or your own), or any git host." },
132+ "role": { "type": "string", "enum": ["follower", "leader"], "description": "follower: g1t leads and pushes to it. leader: this empty repository becomes its mirror." },
133+ "url": { "type": "string", "description": "Its https clone address, such as https://g1t.sh/acme/web.git." },
134+ "username": { "type": "string", "description": "The user the token is sent as. x-access-token when left out." },
135+ "token": { "type": "string", "description": "A token that can read and push. Kept sealed; never returned." },
136+ }),
137+ &["repo", "provider", "role", "url", "token"],
138+ ),
139+ MirrorsOp::UpdateRemote => (
140+ json!({
141+ "repo": repo,
142+ "id": id,
143+ "notify": { "type": "string", "enum": ["banner", "inbox"] },
144+ "take_over_after": { "type": ["integer", "null"], "minimum": 5, "maximum": 1440 },
145+ "hand_back": { "type": "string", "enum": ["when_clean", "ask"] },
146+ "keep_ci_warm": { "type": "boolean" },
147+ "github_workflows": { "type": "boolean" },
148+ "hold_deploys": { "type": "boolean" },
149+ "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] },
150+ }),
151+ &["repo", "id"],
152+ ),
153+ MirrorsOp::RemoveRemote => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
154+ };
155+ json!({ "type": "object", "properties": properties, "required": required })
156+ }
157+}
158+
159+fn text(input: &Value, key: &str) -> String {
160+ input[key].as_str().map(str::trim).unwrap_or_default().to_owned()
161+}
162+
163+/// A boolean as sent: JSON, or a word from a form.
164+fn flag(value: &Value) -> Option<bool> {
165+ match value {
166+ Value::Bool(flag) => Some(*flag),
167+ Value::String(word) => match word.trim().to_ascii_lowercase().as_str() {
168+ "true" | "1" => Some(true),
169+ "false" | "0" => Some(false),
170+ _ => None,
171+ },
172+ _ => None,
173+ }
174+}
175+
176+/// `settings` with what `input` changes, in `snake_case` as sent.
177+pub fn settings_from(mut settings: MirrorSettings, input: &Value) -> std::result::Result<MirrorSettings, String> {
178+ let word = |key: &str| input.get(key).filter(|value| !value.is_null()).map(|value| value.as_str().unwrap_or_default());
179+ if let Some(notify) = word("notify") {
180+ settings.notify = serde_json::from_value(json!(notify)).map_err(|_| "notify is banner or inbox.")?;
181+ }
182+ if let Some(value) = input.get("take_over_after") {
183+ settings.take_over_after = match value {
184+ Value::Null => None,
185+ value => Some(value.as_u64().map(|minutes| minutes as u32).ok_or("take_over_after is a number of minutes, or null.")?),
186+ };
187+ }
188+ if let Some(hand_back) = word("hand_back") {
189+ settings.hand_back = serde_json::from_value(json!(hand_back)).map_err(|_| "hand_back is when_clean or ask.")?;
190+ }
191+ if let Some(remote_pushes) = word("remote_pushes") {
192+ settings.remote_pushes = serde_json::from_value(json!(remote_pushes)).map_err(|_| "remote_pushes is adopt or overwrite.")?;
193+ }
194+ for (key, field) in [
195+ ("keep_ci_warm", &mut settings.keep_ci_warm),
196+ ("github_workflows", &mut settings.github_workflows),
197+ ("hold_deploys", &mut settings.hold_deploys),
198+ ] {
199+ if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
200+ *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?;
201+ }
202+ }
203+ Ok(settings)
204+}
205+
206+/// `decisions` as sent: ref to `keep_ours`, `keep_theirs` or `pull_request`.
207+pub fn decisions_from(input: &Value) -> std::result::Result<BTreeMap<String, RefDecision>, String> {
208+ let Some(decisions) = input.get("decisions").filter(|value| !value.is_null()) else {
209+ return Ok(BTreeMap::new());
210+ };
211+ let Some(decisions) = decisions.as_object() else {
212+ return Err("decisions is an object from ref to keep_ours, keep_theirs or pull_request.".to_owned());
213+ };
214+ decisions
215+ .iter()
216+ .map(|(git_ref, decision)| {
217+ let decision = serde_json::from_value(decision.clone())
218+ .map_err(|_| format!("{git_ref}: say keep_ours, keep_theirs or pull_request."))?;
219+ let git_ref = if git_ref.starts_with("refs/") { git_ref.clone() } else { format!("refs/heads/{git_ref}") };
220+ Ok((git_ref, decision))
221+ })
222+ .collect()
223+}
224+
225+pub async fn run(op: MirrorsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
226+ let Some(path) = repo_path(input) else {
227+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
228+ };
229+ let found: Outcome<Repo> = g1t_kit::call(&services.repos, "get", &GetArgs { path, viewer: viewer.clone() }).await?;
230+ let repo = match found {
231+ Outcome::Ok(repo) => repo,
232+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
233+ };
234+ let integrations = &services.integrations;
235+ if op == MirrorsOp::GetMirror {
236+ return g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await;
237+ }
238+ let Some(actor) = viewer.clone() else {
239+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to change a repository's mirroring."));
240+ };
241+ let act = || MirrorActArgs { actor: actor.clone(), repo_id: repo.id.clone() };
242+ let id = text(input, "id");
243+ if matches!(op, MirrorsOp::UpdateRemote | MirrorsOp::RemoveRemote) && id.is_empty() {
244+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the remote by its id (rmt_…), from get_mirror."));
245+ }
246+ match op {
247+ MirrorsOp::GetMirror => unreachable!("answered above"),
248+ MirrorsOp::GetHandBackPlan => g1t_kit::call(integrations, "mirror_hand_back_plan", &act()).await,
249+ MirrorsOp::TakeOver => g1t_kit::call(integrations, "mirror_take_over", &act()).await,
250+ MirrorsOp::SyncMirror => g1t_kit::call(integrations, "mirror_sync", &act()).await,
251+ MirrorsOp::SetCiFailover => {
252+ let Some(on) = flag(&input["on"]) else {
253+ return Ok(Outcome::fail(FailureCode::Invalid, "Say on: true to start CI failover, or false to end it."));
254+ };
255+ g1t_kit::call(integrations, "mirror_ci", &MirrorCiArgs { actor, repo_id: repo.id, on }).await
256+ }
257+ MirrorsOp::HandBack => {
258+ let decisions = match decisions_from(input) {
259+ Ok(decisions) => decisions,
260+ Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
261+ };
262+ g1t_kit::call(integrations, "mirror_hand_back", &MirrorHandBackArgs { actor, repo_id: repo.id, decisions }).await
263+ }
264+ MirrorsOp::MoveToG1t => {
265+ let keep_remote_updated = match &input["keep_remote_updated"] {
266+ Value::Null => false,
267+ value => match flag(value) {
268+ Some(keep) => keep,
269+ None => return Ok(Outcome::fail(FailureCode::Invalid, "keep_remote_updated is true or false.")),
270+ },
271+ };
272+ g1t_kit::call(integrations, "mirror_move_in", &MirrorMoveInArgs { actor, repo_id: repo.id, keep_remote_updated }).await
273+ }
274+ MirrorsOp::AddRemote => {
275+ let provider = match text(input, "provider").as_str() {
276+ "g1t" => RemoteProvider::G1t,
277+ "git" => RemoteProvider::Git,
278+ "github" => {
279+ return Ok(Outcome::fail(
280+ FailureCode::Invalid,
281+ "GitHub repositories are linked by importing them through g1t's GitHub App.",
282+ ));
283+ }
284+ _ => return Ok(Outcome::fail(FailureCode::Invalid, "provider is g1t or git.")),
285+ };
286+ let role = match text(input, "role").as_str() {
287+ "leader" => RemoteRole::Leader,
288+ "follower" => RemoteRole::Follower,
289+ _ => return Ok(Outcome::fail(FailureCode::Invalid, "role is follower (g1t leads) or leader (the remote leads).")),
290+ };
291+ let username = Some(text(input, "username")).filter(|name| !name.is_empty());
292+ let token = Some(text(input, "token")).filter(|token| !token.is_empty());
293+ let args = MirrorAddArgs { actor, repo_id: repo.id, provider, role, url: text(input, "url"), username, token };
294+ g1t_kit::call(integrations, "mirror_add", &args).await
295+ }
296+ MirrorsOp::UpdateRemote => {
297+ let view: Outcome<g1t_contracts::mirrors::MirrorView> =
298+ g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
299+ let view = match view {
300+ Outcome::Ok(view) => view,
301+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
302+ };
303+ let Some(remote) = view.remotes.into_iter().find(|remote| remote.id == id) else {
304+ return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
305+ };
306+ let settings = match settings_from(remote.settings, input) {
307+ Ok(settings) => settings,
308+ Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
309+ };
310+ g1t_kit::call(integrations, "mirror_settings", &MirrorSettingsArgs { actor, remote_id: id, settings }).await
311+ }
312+ MirrorsOp::RemoveRemote => {
313+ let view: Outcome<g1t_contracts::mirrors::MirrorView> =
314+ g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
315+ if !matches!(&view, Outcome::Ok(view) if view.remotes.iter().any(|remote| remote.id == id)) {
316+ return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
317+ }
318+ g1t_kit::call(integrations, "mirror_remove", &MirrorRemoveArgs { actor, remote_id: id }).await
319+ }
320+ }
321+}
322+
323+#[cfg(test)]
324+mod tests {
325+ use super::*;
326+ use g1t_contracts::credentials::NEVER;
327+ use g1t_contracts::mirrors::{HandBack, Notify, RemotePushes};
328+ use g1t_contracts::scopes::{Level, scope_for};
329+
330+ #[test]
331+ fn each_operation_is_described_and_scoped() {
332+ for op in MirrorsOp::ALL {
333+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Mirrors(op)), "{}", op.name());
334+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
335+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
336+ let level = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())).level();
337+ let expected = match op {
338+ MirrorsOp::GetMirror => Level::Read,
339+ MirrorsOp::SyncMirror => Level::Write,
340+ _ => Level::Admin,
341+ };
342+ assert_eq!(level, expected, "{}", op.name());
343+ }
344+ for op in [MirrorsOp::MoveToG1t, MirrorsOp::AddRemote, MirrorsOp::UpdateRemote, MirrorsOp::RemoveRemote] {
345+ assert!(NEVER.contains(&op.name()), "agents never {}", op.name());
346+ }
347+ }
348+
349+ #[test]
350+ fn settings_change_only_what_is_sent() {
351+ let base = MirrorSettings::default();
352+ let changed = settings_from(base.clone(), &json!({ "notify": "inbox", "take_over_after": 30, "keep_ci_warm": "true" })).unwrap();
353+ assert_eq!(changed.notify, Notify::Inbox);
354+ assert_eq!(changed.take_over_after, Some(30));
355+ assert!(changed.keep_ci_warm);
356+ assert_eq!(changed.hand_back, HandBack::WhenClean);
357+ assert_eq!(changed.remote_pushes, RemotePushes::Adopt);
358+ let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap();
359+ assert_eq!(cleared.take_over_after, None);
360+ assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err());
361+ assert!(settings_from(base, &json!({ "hold_deploys": "maybe" })).is_err());
362+ }
363+
364+ #[test]
365+ fn decisions_name_refs_or_branches() {
366+ let decisions = decisions_from(&json!({ "decisions": { "docs": "keep_theirs", "refs/tags/v1": "keep_ours" } })).unwrap();
367+ assert_eq!(decisions.get("refs/heads/docs"), Some(&RefDecision::KeepTheirs));
368+ assert_eq!(decisions.get("refs/tags/v1"), Some(&RefDecision::KeepOurs));
369+ assert!(decisions_from(&json!({ "decisions": { "docs": "merge" } })).is_err());
370+ assert!(decisions_from(&json!({})).unwrap().is_empty());
371+ }
372+}
+18−0
1010 use crate::about::AboutOp;
1111 use crate::artifacts::ArtifactsOp;
1212 use crate::deploy_keys::DeployKeysOp;
13+use crate::mirrors::MirrorsOp;
1314 use crate::deployments::DeploymentsOp;
1415 use crate::packages::PackagesOp;
1516 use crate::protection::ProtectionOp;
186187 ],
187188 ),
188189 (
190+ "Mirroring",
191+ "A repository's links to copies of it on other hosts: a mirror stands by as a read-only copy of a remote that leads, until someone takes over on g1t and later hands back, or moves it to g1t for good. A repository g1t leads can be mirrored to other hosts.",
192+ &[
193+ Op::Mirrors(MirrorsOp::GetMirror),
194+ Op::Mirrors(MirrorsOp::GetHandBackPlan),
195+ Op::Mirrors(MirrorsOp::TakeOver),
196+ Op::Mirrors(MirrorsOp::SetCiFailover),
197+ Op::Mirrors(MirrorsOp::HandBack),
198+ Op::Mirrors(MirrorsOp::MoveToG1t),
199+ Op::Mirrors(MirrorsOp::SyncMirror),
200+ Op::Mirrors(MirrorsOp::AddRemote),
201+ Op::Mirrors(MirrorsOp::UpdateRemote),
202+ Op::Mirrors(MirrorsOp::RemoveRemote),
203+ ],
204+ ),
205+ (
189206 "Teams",
190207 "Groups of a workspace's members: given a role on repositories together, mentioned together as @workspace/team, and asked to review together. Any member may create a team; the workspace's owners and the team's maintainers manage it.",
191208 &[
733750 Op::Tokens(op) => op.title(),
734751 Op::Artifacts(op) => op.title(),
735752 Op::DeployKeys(op) => op.title(),
753+ Op::Mirrors(op) => op.title(),
736754 Op::Packages(op) => op.title(),
737755 }
738756 }
+20−1
3030 use crate::about::AboutOp;
3131 use crate::artifacts::ArtifactsOp;
3232 use crate::deploy_keys::DeployKeysOp;
33+use crate::mirrors::MirrorsOp;
3334 use crate::deployments::DeploymentsOp;
3435 use crate::packages::PackagesOp;
3536 use crate::protection::ProtectionOp;
314315 Artifacts(ArtifactsOp),
315316 /// A repository's deploy keys: deploy_keys.rs.
316317 DeployKeys(DeployKeysOp),
318+ /// A repository's mirroring: its remotes, takeovers and hand-backs:
319+ /// mirrors.rs.
320+ Mirrors(MirrorsOp),
317321 /// A workspace's packages, their versions, deleting and restoring
318322 /// them, and who may use them: packages.rs.
319323 Packages(PackagesOp),
686690 }
687691
688692 impl Op {
689− pub const ALL: [Op; 318] = [
693+ pub const ALL: [Op; 328] = [
690694 Op::Whoami,
691695 Op::GetWorkspace,
692696 Op::CreateWorkspace,
968972 Op::DeployKeys(DeployKeysOp::GetDeployKey),
969973 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
970974 Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
975+ Op::Mirrors(MirrorsOp::GetMirror),
976+ Op::Mirrors(MirrorsOp::GetHandBackPlan),
977+ Op::Mirrors(MirrorsOp::TakeOver),
978+ Op::Mirrors(MirrorsOp::SetCiFailover),
979+ Op::Mirrors(MirrorsOp::HandBack),
980+ Op::Mirrors(MirrorsOp::MoveToG1t),
981+ Op::Mirrors(MirrorsOp::SyncMirror),
982+ Op::Mirrors(MirrorsOp::AddRemote),
983+ Op::Mirrors(MirrorsOp::UpdateRemote),
984+ Op::Mirrors(MirrorsOp::RemoveRemote),
971985 Op::Protection(ProtectionOp::UpdateEnvironment),
972986 Op::Protection(ProtectionOp::DeleteEnvironment),
973987 Op::Protection(ProtectionOp::GetPendingDeployments),
12151229 Op::Tokens(op) => op.name(),
12161230 Op::Artifacts(op) => op.name(),
12171231 Op::DeployKeys(op) => op.name(),
1232+ Op::Mirrors(op) => op.name(),
12181233 Op::Packages(op) => op.name(),
12191234 }
12201235 }
17701785 Op::Tokens(op) => op.description(),
17711786 Op::Artifacts(op) => op.description(),
17721787 Op::DeployKeys(op) => op.description(),
1788+ Op::Mirrors(op) => op.description(),
17731789 Op::Packages(op) => op.description(),
17741790 }
17751791 }
32543270 Op::Tokens(op) => op.input(),
32553271 Op::Artifacts(op) => op.input(),
32563272 Op::DeployKeys(op) => op.input(),
3273+ Op::Mirrors(op) => op.input(),
32573274 Op::Packages(op) => op.input(),
32583275 }
32593276 }
43554372 is_private: input["private"].as_bool() == Some(true),
43564373 import_url: optional_text(input, "import_url"),
43574374 import_token: None,
4375+ mirror: None,
43584376 },
43594377 )
43604378 .await
55725590 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
55735591 Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
55745592 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
5593+ Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
55755594 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
55765595 Op::ReopenSecurityAlert => {
55775596 let changed: Outcome<AlertChange> = call(
+372−0
1222112221 }
1222212222 ]
1222312223 }
12224+ },
12225+ "get_mirror": {
12226+ "params": {
12227+ "owner": "acme",
12228+ "name": "web"
12229+ },
12230+ "response": {
12231+ "remotes": [
12232+ {
12233+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12234+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12235+ "repo": "acme/web",
12236+ "provider": "github",
12237+ "role": "leader",
12238+ "name": "github.com/acme/web",
12239+ "url": "https://github.com/acme/web",
12240+ "state": "standby",
12241+ "state_since": "2026-10-08T13:58:00.000Z",
12242+ "state_by": "syntaqx",
12243+ "reachable": true,
12244+ "unreachable_since": null,
12245+ "synced_at": "2026-10-08T13:57:42.000Z",
12246+ "last_error": null,
12247+ "settings": {
12248+ "notify": "banner",
12249+ "take_over_after": null,
12250+ "hand_back": "when_clean",
12251+ "keep_ci_warm": false,
12252+ "github_workflows": true,
12253+ "hold_deploys": true,
12254+ "remote_pushes": "adopt"
12255+ },
12256+ "created_at": "2026-10-05T10:00:00.000Z"
12257+ }
12258+ ],
12259+ "plan": null,
12260+ "can_manage": true
12261+ },
12262+ "notes": "A repository that leads lists its followers instead, each `role: follower` with `state` `following` or `stuck`. A mirror standing by is read-only on g1t: pushes, merges, issues, pull requests and agents are refused, and nothing runs. `reachable` is false once the remote's host has failed three checks over two minutes; by default that is only shown, and nothing happens on its own."
12263+ },
12264+ "get_hand_back_plan": {
12265+ "params": {
12266+ "owner": "acme",
12267+ "name": "web"
12268+ },
12269+ "response": {
12270+ "remotes": [
12271+ {
12272+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12273+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12274+ "repo": "acme/web",
12275+ "provider": "github",
12276+ "role": "leader",
12277+ "name": "github.com/acme/web",
12278+ "url": "https://github.com/acme/web",
12279+ "state": "takeover",
12280+ "state_since": "2026-10-08T13:58:00.000Z",
12281+ "state_by": "syntaqx",
12282+ "reachable": true,
12283+ "unreachable_since": null,
12284+ "synced_at": "2026-10-08T13:57:42.000Z",
12285+ "last_error": null,
12286+ "settings": {
12287+ "notify": "banner",
12288+ "take_over_after": null,
12289+ "hand_back": "when_clean",
12290+ "keep_ci_warm": false,
12291+ "github_workflows": true,
12292+ "hold_deploys": true,
12293+ "remote_pushes": "adopt"
12294+ },
12295+ "created_at": "2026-10-05T10:00:00.000Z"
12296+ }
12297+ ],
12298+ "plan": {
12299+ "refs": [
12300+ {
12301+ "ref": "refs/heads/fix/login-timeout",
12302+ "base": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12303+ "ours": "c47f2b8e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a",
12304+ "theirs": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12305+ "action": "push",
12306+ "decision": null
12307+ },
12308+ {
12309+ "ref": "refs/heads/main",
12310+ "base": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12311+ "ours": "e01d9a37f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1",
12312+ "theirs": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12313+ "action": "pull_request",
12314+ "decision": null
12315+ },
12316+ {
12317+ "ref": "refs/heads/docs/readme",
12318+ "base": "7f3a1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
12319+ "ours": "5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c",
12320+ "theirs": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
12321+ "action": "diverged",
12322+ "decision": null
12323+ }
12324+ ],
12325+ "reachable": true,
12326+ "ready": false
12327+ },
12328+ "can_manage": true
12329+ },
12330+ "notes": "Refs that did not move on either side are left out. `ready` stays false until `docs/readme` has a decision."
12331+ },
12332+ "take_over_mirror": {
12333+ "params": {
12334+ "owner": "acme",
12335+ "name": "web"
12336+ },
12337+ "response": {
12338+ "remotes": [
12339+ {
12340+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12341+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12342+ "repo": "acme/web",
12343+ "provider": "github",
12344+ "role": "leader",
12345+ "name": "github.com/acme/web",
12346+ "url": "https://github.com/acme/web",
12347+ "state": "takeover",
12348+ "state_since": "2026-10-08T13:58:00.000Z",
12349+ "state_by": "syntaqx",
12350+ "reachable": false,
12351+ "unreachable_since": "2026-10-08T14:04:00.000Z",
12352+ "synced_at": "2026-10-08T13:57:42.000Z",
12353+ "last_error": null,
12354+ "settings": {
12355+ "notify": "banner",
12356+ "take_over_after": null,
12357+ "hand_back": "when_clean",
12358+ "keep_ci_warm": false,
12359+ "github_workflows": true,
12360+ "hold_deploys": true,
12361+ "remote_pushes": "adopt"
12362+ },
12363+ "created_at": "2026-10-05T10:00:00.000Z"
12364+ }
12365+ ],
12366+ "plan": null,
12367+ "can_manage": true
12368+ }
12369+ },
12370+ "set_ci_failover": {
12371+ "params": {
12372+ "owner": "acme",
12373+ "name": "web"
12374+ },
12375+ "request": {
12376+ "on": true
12377+ },
12378+ "response": {
12379+ "remotes": [
12380+ {
12381+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12382+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12383+ "repo": "acme/web",
12384+ "provider": "github",
12385+ "role": "leader",
12386+ "name": "github.com/acme/web",
12387+ "url": "https://github.com/acme/web",
12388+ "state": "ci",
12389+ "state_since": "2026-10-08T13:58:00.000Z",
12390+ "state_by": "syntaqx",
12391+ "reachable": true,
12392+ "unreachable_since": null,
12393+ "synced_at": "2026-10-08T13:57:42.000Z",
12394+ "last_error": null,
12395+ "settings": {
12396+ "notify": "banner",
12397+ "take_over_after": null,
12398+ "hand_back": "when_clean",
12399+ "keep_ci_warm": false,
12400+ "github_workflows": true,
12401+ "hold_deploys": true,
12402+ "remote_pushes": "adopt"
12403+ },
12404+ "created_at": "2026-10-05T10:00:00.000Z"
12405+ }
12406+ ],
12407+ "plan": null,
12408+ "can_manage": true
12409+ }
12410+ },
12411+ "hand_back_mirror": {
12412+ "params": {
12413+ "owner": "acme",
12414+ "name": "web"
12415+ },
12416+ "request": {
12417+ "decisions": {
12418+ "refs/heads/docs/readme": "pull_request"
12419+ }
12420+ },
12421+ "response": {
12422+ "remotes": [
12423+ {
12424+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12425+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12426+ "repo": "acme/web",
12427+ "provider": "github",
12428+ "role": "leader",
12429+ "name": "github.com/acme/web",
12430+ "url": "https://github.com/acme/web",
12431+ "state": "standby",
12432+ "state_since": "2026-10-08T13:58:00.000Z",
12433+ "state_by": "syntaqx",
12434+ "reachable": true,
12435+ "unreachable_since": null,
12436+ "synced_at": "2026-10-08T13:57:42.000Z",
12437+ "last_error": null,
12438+ "settings": {
12439+ "notify": "banner",
12440+ "take_over_after": null,
12441+ "hand_back": "when_clean",
12442+ "keep_ci_warm": false,
12443+ "github_workflows": true,
12444+ "hold_deploys": true,
12445+ "remote_pushes": "adopt"
12446+ },
12447+ "created_at": "2026-10-05T10:00:00.000Z"
12448+ }
12449+ ],
12450+ "plan": null,
12451+ "can_manage": true,
12452+ "notes": [
12453+ "main: opened https://github.com/acme/web/pull/1290",
12454+ "docs/readme: opened https://github.com/acme/web/pull/1291"
12455+ ]
12456+ },
12457+ "notes": "Branch names work as keys too: `{ \"docs/readme\": \"keep_theirs\" }`."
12458+ },
12459+ "move_mirror_to_g1t": {
12460+ "params": {
12461+ "owner": "acme",
12462+ "name": "web"
12463+ },
12464+ "request": {
12465+ "keep_remote_updated": false
12466+ },
12467+ "response": {
12468+ "remotes": [],
12469+ "plan": null,
12470+ "can_manage": true
12471+ },
12472+ "notes": "With `keep_remote_updated: true` the remote stays in `remotes` as a follower, and g1t pushes to it from then on."
12473+ },
12474+ "sync_mirror": {
12475+ "params": {
12476+ "owner": "acme",
12477+ "name": "web"
12478+ },
12479+ "response": {
12480+ "remotes": [
12481+ {
12482+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12483+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12484+ "repo": "acme/web",
12485+ "provider": "github",
12486+ "role": "leader",
12487+ "name": "github.com/acme/web",
12488+ "url": "https://github.com/acme/web",
12489+ "state": "standby",
12490+ "state_since": "2026-10-08T13:58:00.000Z",
12491+ "state_by": "syntaqx",
12492+ "reachable": true,
12493+ "unreachable_since": null,
12494+ "synced_at": "2026-10-08T13:57:42.000Z",
12495+ "last_error": null,
12496+ "settings": {
12497+ "notify": "banner",
12498+ "take_over_after": null,
12499+ "hand_back": "when_clean",
12500+ "keep_ci_warm": false,
12501+ "github_workflows": true,
12502+ "hold_deploys": true,
12503+ "remote_pushes": "adopt"
12504+ },
12505+ "created_at": "2026-10-05T10:00:00.000Z"
12506+ }
12507+ ],
12508+ "plan": null,
12509+ "can_manage": true
12510+ }
12511+ },
12512+ "add_mirror_remote": {
12513+ "params": {
12514+ "owner": "acme",
12515+ "name": "web"
12516+ },
12517+ "request": {
12518+ "provider": "g1t",
12519+ "role": "follower",
12520+ "url": "https://git.acme.internal/acme/web.git",
12521+ "username": "deploy",
12522+ "token": "g1t_…"
12523+ },
12524+ "response": {
12525+ "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n",
12526+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12527+ "repo": "acme/web",
12528+ "provider": "g1t",
12529+ "role": "follower",
12530+ "name": "git.acme.internal/acme/web",
12531+ "url": "https://git.acme.internal/acme/web",
12532+ "state": "following",
12533+ "state_since": "2026-10-08T13:58:00.000Z",
12534+ "state_by": "syntaqx",
12535+ "reachable": true,
12536+ "unreachable_since": null,
12537+ "synced_at": "2026-10-08T13:57:42.000Z",
12538+ "last_error": null,
12539+ "settings": {
12540+ "notify": "banner",
12541+ "take_over_after": null,
12542+ "hand_back": "when_clean",
12543+ "keep_ci_warm": false,
12544+ "github_workflows": true,
12545+ "hold_deploys": true,
12546+ "remote_pushes": "adopt"
12547+ },
12548+ "created_at": "2026-10-05T10:00:00.000Z"
12549+ },
12550+ "notes": "The token is never returned. A remote that refused it, or could not be reached, is still added, with `last_error` saying why."
12551+ },
12552+ "update_mirror_remote": {
12553+ "params": {
12554+ "owner": "acme",
12555+ "name": "web",
12556+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m"
12557+ },
12558+ "request": {
12559+ "notify": "inbox",
12560+ "take_over_after": 30
12561+ },
12562+ "response": {
12563+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12564+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12565+ "repo": "acme/web",
12566+ "provider": "github",
12567+ "role": "leader",
12568+ "name": "github.com/acme/web",
12569+ "url": "https://github.com/acme/web",
12570+ "state": "standby",
12571+ "state_since": "2026-10-08T13:58:00.000Z",
12572+ "state_by": "syntaqx",
12573+ "reachable": true,
12574+ "unreachable_since": null,
12575+ "synced_at": "2026-10-08T13:57:42.000Z",
12576+ "last_error": null,
12577+ "settings": {
12578+ "notify": "inbox",
12579+ "take_over_after": 30,
12580+ "hand_back": "when_clean",
12581+ "keep_ci_warm": false,
12582+ "github_workflows": true,
12583+ "hold_deploys": true,
12584+ "remote_pushes": "adopt"
12585+ },
12586+ "created_at": "2026-10-05T10:00:00.000Z"
12587+ }
12588+ },
12589+ "remove_mirror_remote": {
12590+ "params": {
12591+ "owner": "acme",
12592+ "name": "web",
12593+ "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n"
12594+ },
12595+ "response": true
1222412596 }
1222512597 }
+12−0
55 use crate::about::AboutOp;
66 use crate::artifacts::ArtifactsOp;
77 use crate::deploy_keys::DeployKeysOp;
8+use crate::mirrors::MirrorsOp;
89 use crate::deployments::DeploymentsOp;
910 use crate::packages::PackagesOp;
1011 use crate::protection::ProtectionOp;
8485 route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]),
8586 route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]),
8687 route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]),
88+ // Mirroring: a repository's remotes, takeovers and hand-backs.
89+ route("GET", "/repos/:owner/:name/mirror", Op::Mirrors(MirrorsOp::GetMirror), &[]),
90+ route("GET", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::GetHandBackPlan), &[]),
91+ route("POST", "/repos/:owner/:name/mirror/take-over", Op::Mirrors(MirrorsOp::TakeOver), &[]),
92+ route("POST", "/repos/:owner/:name/mirror/ci", Op::Mirrors(MirrorsOp::SetCiFailover), &[]),
93+ route("POST", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::HandBack), &[]),
94+ route("POST", "/repos/:owner/:name/mirror/move-to-g1t", Op::Mirrors(MirrorsOp::MoveToG1t), &[]),
95+ route("POST", "/repos/:owner/:name/mirror/sync", Op::Mirrors(MirrorsOp::SyncMirror), &[]),
96+ route("POST", "/repos/:owner/:name/mirror/remotes", Op::Mirrors(MirrorsOp::AddRemote), &[]),
97+ route("PATCH", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::UpdateRemote), &[]),
98+ route("DELETE", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::RemoveRemote), &[]),
8799 // Your notifications: threads, marking them, and what you subscribe
88100 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
89101 route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
+12−1
2121 use crate::about::AboutOp;
2222 use crate::artifacts::ArtifactsOp;
2323 use crate::deploy_keys::DeployKeysOp;
24+use crate::mirrors::MirrorsOp;
2425 use crate::deployments::DeploymentsOp;
2526 use crate::packages::PackagesOp;
2627 use crate::protection::ProtectionOp;
6768 Tool {
6869 name: "repository",
6970 title: "Repositories",
70− description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
71+ description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, publish releases, and look after their mirroring (take a mirror over, hand it back, or move it to g1t for good). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
7172 default_action: None,
7273 actions: &[
7374 a("list", Op::ListRepos, "Repositories you can see"),
114115 a("rename_branch", Op::RenameBranch, "Rename a branch"),
115116 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
116117 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
118+ a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"),
119+ a("mirror_hand_back_plan", Op::Mirrors(MirrorsOp::GetHandBackPlan), "What handing a takeover back would do, ref by ref"),
120+ a("mirror_take_over", Op::Mirrors(MirrorsOp::TakeOver), "Make g1t lead a mirror for now"),
121+ a("mirror_ci", Op::Mirrors(MirrorsOp::SetCiFailover), "Run a mirror's workflows on g1t (on), or stop (off)"),
122+ a("mirror_hand_back", Op::Mirrors(MirrorsOp::HandBack), "Send a takeover back, deciding diverged refs"),
123+ a("mirror_move_to_g1t", Op::Mirrors(MirrorsOp::MoveToG1t), "Stop tracking the remote; g1t leads for good"),
124+ a("mirror_sync", Op::Mirrors(MirrorsOp::SyncMirror), "Bring its remotes in step now"),
125+ a("mirror_add_remote", Op::Mirrors(MirrorsOp::AddRemote), "Link another g1t or git host"),
126+ a("mirror_update_remote", Op::Mirrors(MirrorsOp::UpdateRemote), "Change a remote's settings"),
127+ a("mirror_remove_remote", Op::Mirrors(MirrorsOp::RemoveRemote), "Unlink a remote"),
117128 a("archive", Op::ArchiveRepo, "Make it read-only"),
118129 a("unarchive", Op::UnarchiveRepo, "Make it writable again"),
119130 a("set_visibility", Op::SetRepoVisibility, "Make it public or private"),
+1−0
159159 items: [
160160 { label: 'Accounts and sign-in', slug: 'guides/authentication' },
161161 { label: 'GitHub', slug: 'guides/github' },
162+ { label: 'Mirroring', slug: 'guides/mirroring' },
162163 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
163164 { label: 'Access and roles', slug: 'guides/access-and-roles' },
164165 { 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
8787 | Choice | What happens | Where you push |
8888 | --- | --- | --- |
8989 | **Import** | Copied once. The g1t repository is then its own. | g1t |
90−| **Mirror** | Copied, then every push to GitHub is fetched into g1t within seconds. Branches deleted on GitHub are deleted on g1t. | GitHub |
91−| **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 |
90+| **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 |
91+| **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 |
9292
93−A mirror is a copy that follows GitHub: anything pushed to the g1t copy
94−directly is overwritten at the next sync. Deployments, previews, checks
95−and agents run on the copy as on any project.
93+A standby mirror is a backup you can switch to: if GitHub is down, or
94+whenever you want to work on g1t for a while, **take over** in its
95+**Settings → Mirroring**, and **hand back** when you're done. You can also
96+run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t
97+for good. See [mirroring](/guides/mirroring/).
9698
9799 ### What comes across
98100
105107 | Issue numbers | New numbers on g1t; each issue links to the original |
106108 | Comments on issues | No |
107109 | Pull requests | No. Their branches come across; open pull requests stay on GitHub. |
108−| GitHub Actions workflows | Yes, as files. Rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). |
110+| 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/). |
109111 | Releases, wikis, Git LFS objects | No |
110112
111113 A repository is copied in one piece of at most 40 MB, after compression.
113115
114116 ### Keep a mirror in step
115117
116−The repository's overview shows where it came from and when it last
117−synced. **Sync now** copies at once; **Stop mirroring** (or **Stop
118−pushing**) ends the tie and leaves the g1t repository as it is. A mirror
119−also stops, keeping its copy, when:
118+The repository's **Settings → Mirroring** shows the link: its state,
119+whether GitHub is answering, and when it last synced. **Sync now** copies
120+at once. **Take over**, **Hand back**, **Move to g1t** and **Remove** are
121+there too: see [mirroring](/guides/mirroring/). A standby mirror stops,
122+keeping its copy, when:
120123
121124 - the repository is deleted on GitHub,
122125 - the app is uninstalled from its GitHub account, or
123126 - a workspace owner removes that GitHub account from the workspace.
124127
125−If GitHub stops letting the app see a repository, its overview says so;
126−add it back to the installation on GitHub to carry on. Renaming or
128+If GitHub stops letting the app see a repository, its settings say so; add
129+it back to the installation on GitHub to carry on. Renaming or
127130 transferring a repository on GitHub keeps the mirror working.
128131
129132 ## What g1t's GitHub App can do
130133
131134 | Permission | Access | Why |
132135 | --- | --- | --- |
133−| Contents | Read and write | Read: clone the repositories you choose. Write: only for **Move to g1t**, to push. |
134−| Metadata | Read | Required by GitHub for every app: names and visibility. |
136+| Contents | Read and write | Read: clone the repositories you choose. Write: to push for **Move to g1t**, and to hand a takeover back. |
137+| Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. |
135138 | Issues | Read | Copy issues when you ask. |
139+| 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. |
136140 | Email addresses (account) | Read | Find your verified email when you sign in. |
137141
138142 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.
+0−0

Binary or large file; its contents are not shown.

+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+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.