GitHub mirroring is gone: a repository comes across from GitHub by importing it, once, and is then its own on g1t. Standby mirrors, CI failover, takeovers, hand-backs, moving in and pushing back to GitHub made every part of g1t ask whether a repository leads, and that was more than the product wants to carry. Connecting a GitHub account or installing g1t's GitHub App, and importing repositories through it with every branch, tag and, if asked, their issues, work as before.
What went: Settings → Mirroring and its page, the mirror badges, banners, read-only pages and disabled buttons; the "How it comes across" choice on Import from GitHub and the mirror card on New project; the Mirrors filter on Projects; the /repos/{owner}/{name}/mirror endpoints and the repository tool's mirror_* MCP actions, with their scopes; the mirror.* webhook events and inbox threads; the integrations service's remotes, its every-minute cron and its events binding, and its handling of GitHub pushes and commit statuses; the repos service's mirror, mirror_refs, mirror_apply and set_mirror methods (the git copy an import uses stays, as copy.rs); a mirror's workflow and deploy policy in actions and deployments; and the mirror fields in the contracts (Repo.mirror, RepoStatus.mirror, the git.push event's mirrored and mirror, GithubMode, ProjectSource's mirror). An archived repository is now the only read-only one. The integrations service no longer hears git.push or status.created. The docs lose the Mirroring guide (its address goes to the GitHub guide), and the GitHub, GitHub Actions, Deployments, Security, Projects, Git, Integrations, self-hosting and limitations pages say what importing does, with the GitHub App's permissions down to Contents, Metadata and Issues read. The API reference is regenerated. No migration drops anything: repos.mirror, the integrations tables remotes, remote_refs and remote_hosts, and github_repos.mode are no longer read (mode is still written, as import).
| 77 | 77 | | Packages: npm, Cargo, Composer, Maven, NuGet, RubyGems, Go modules, container images | Live | | |
| 78 | 78 | | Secret push protection, history scanning, dependency upgrade pull requests | Live | | |
| 79 | 79 | | A preview of every pull request on `g1t.page`, production on merge, custom domains | Live | | |
| 80 | − | | GitHub import, mirroring and pushing back | Live | | |
| 80 | + | | GitHub import: every branch and tag, and issues | Live | | |
| 81 | 81 | | A copy of the data for every preview, instant rollback, container apps kept warm | Coming | | |
| 82 | 82 | | Git over SSH (waits on inbound TCP on Workers; use HTTPS) | Coming | | |
| 83 | 83 |
| 19 | 19 | mod deploy_keys; | |
| 20 | 20 | mod limits; | |
| 21 | 21 | mod logs; | |
| 22 | − | mod mirrors; | |
| 23 | 22 | mod mcp; | |
| 24 | 23 | mod notifications; | |
| 25 | 24 | mod oauth; |
| 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`, `checks_problem` (why g1t could not report checks to the remote, such as the GitHub App lacking the Commit statuses permission; null once a report goes through) 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`, `report_checks` (a GitHub mirror: report the statuses g1t sets on a commit, such as workflow runs and deploys, to the commit on GitHub as `g1t / <context>` while g1t runs its workflows; on by default), 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 | − | "report_checks": { "type": "boolean" }, | |
| 150 | − | "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] }, | |
| 151 | − | }), | |
| 152 | − | &["repo", "id"], | |
| 153 | − | ), | |
| 154 | − | MirrorsOp::RemoveRemote => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 155 | − | }; | |
| 156 | − | json!({ "type": "object", "properties": properties, "required": required }) | |
| 157 | − | } | |
| 158 | − | } | |
| 159 | − | ||
| 160 | − | fn text(input: &Value, key: &str) -> String { | |
| 161 | − | input[key].as_str().map(str::trim).unwrap_or_default().to_owned() | |
| 162 | − | } | |
| 163 | − | ||
| 164 | − | /// A boolean as sent: JSON, or a word from a form. | |
| 165 | − | fn flag(value: &Value) -> Option<bool> { | |
| 166 | − | match value { | |
| 167 | − | Value::Bool(flag) => Some(*flag), | |
| 168 | − | Value::String(word) => match word.trim().to_ascii_lowercase().as_str() { | |
| 169 | − | "true" | "1" => Some(true), | |
| 170 | − | "false" | "0" => Some(false), | |
| 171 | − | _ => None, | |
| 172 | − | }, | |
| 173 | − | _ => None, | |
| 174 | − | } | |
| 175 | − | } | |
| 176 | − | ||
| 177 | − | /// `settings` with what `input` changes, in `snake_case` as sent. | |
| 178 | − | pub fn settings_from(mut settings: MirrorSettings, input: &Value) -> std::result::Result<MirrorSettings, String> { | |
| 179 | − | let word = |key: &str| input.get(key).filter(|value| !value.is_null()).map(|value| value.as_str().unwrap_or_default()); | |
| 180 | − | if let Some(notify) = word("notify") { | |
| 181 | − | settings.notify = serde_json::from_value(json!(notify)).map_err(|_| "notify is banner or inbox.")?; | |
| 182 | − | } | |
| 183 | − | if let Some(value) = input.get("take_over_after") { | |
| 184 | − | settings.take_over_after = match value { | |
| 185 | − | Value::Null => None, | |
| 186 | − | value => Some(value.as_u64().map(|minutes| minutes as u32).ok_or("take_over_after is a number of minutes, or null.")?), | |
| 187 | − | }; | |
| 188 | − | } | |
| 189 | − | if let Some(hand_back) = word("hand_back") { | |
| 190 | − | settings.hand_back = serde_json::from_value(json!(hand_back)).map_err(|_| "hand_back is when_clean or ask.")?; | |
| 191 | − | } | |
| 192 | − | if let Some(remote_pushes) = word("remote_pushes") { | |
| 193 | − | settings.remote_pushes = serde_json::from_value(json!(remote_pushes)).map_err(|_| "remote_pushes is adopt or overwrite.")?; | |
| 194 | − | } | |
| 195 | − | for (key, field) in [ | |
| 196 | − | ("keep_ci_warm", &mut settings.keep_ci_warm), | |
| 197 | − | ("github_workflows", &mut settings.github_workflows), | |
| 198 | − | ("hold_deploys", &mut settings.hold_deploys), | |
| 199 | − | ("report_checks", &mut settings.report_checks), | |
| 200 | − | ] { | |
| 201 | − | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { | |
| 202 | − | *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?; | |
| 203 | − | } | |
| 204 | − | } | |
| 205 | − | Ok(settings) | |
| 206 | − | } | |
| 207 | − | ||
| 208 | − | /// `decisions` as sent: ref to `keep_ours`, `keep_theirs` or `pull_request`. | |
| 209 | − | pub fn decisions_from(input: &Value) -> std::result::Result<BTreeMap<String, RefDecision>, String> { | |
| 210 | − | let Some(decisions) = input.get("decisions").filter(|value| !value.is_null()) else { | |
| 211 | − | return Ok(BTreeMap::new()); | |
| 212 | − | }; | |
| 213 | − | let Some(decisions) = decisions.as_object() else { | |
| 214 | − | return Err("decisions is an object from ref to keep_ours, keep_theirs or pull_request.".to_owned()); | |
| 215 | − | }; | |
| 216 | − | decisions | |
| 217 | − | .iter() | |
| 218 | − | .map(|(git_ref, decision)| { | |
| 219 | − | let decision = serde_json::from_value(decision.clone()) | |
| 220 | − | .map_err(|_| format!("{git_ref}: say keep_ours, keep_theirs or pull_request."))?; | |
| 221 | − | let git_ref = if git_ref.starts_with("refs/") { git_ref.clone() } else { format!("refs/heads/{git_ref}") }; | |
| 222 | − | Ok((git_ref, decision)) | |
| 223 | − | }) | |
| 224 | − | .collect() | |
| 225 | − | } | |
| 226 | − | ||
| 227 | − | pub async fn run(op: MirrorsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 228 | − | let Some(path) = repo_path(input) else { | |
| 229 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 230 | − | }; | |
| 231 | − | let found: Outcome<Repo> = g1t_kit::call(&services.repos, "get", &GetArgs { path, viewer: viewer.clone() }).await?; | |
| 232 | − | let repo = match found { | |
| 233 | − | Outcome::Ok(repo) => repo, | |
| 234 | − | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 235 | − | }; | |
| 236 | − | let integrations = &services.integrations; | |
| 237 | − | if op == MirrorsOp::GetMirror { | |
| 238 | − | return g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await; | |
| 239 | − | } | |
| 240 | − | let Some(actor) = viewer.clone() else { | |
| 241 | − | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to change a repository's mirroring.")); | |
| 242 | − | }; | |
| 243 | − | let act = || MirrorActArgs { actor: actor.clone(), repo_id: repo.id.clone() }; | |
| 244 | − | let id = text(input, "id"); | |
| 245 | − | if matches!(op, MirrorsOp::UpdateRemote | MirrorsOp::RemoveRemote) && id.is_empty() { | |
| 246 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Name the remote by its id (rmt_…), from get_mirror.")); | |
| 247 | − | } | |
| 248 | − | match op { | |
| 249 | − | MirrorsOp::GetMirror => unreachable!("answered above"), | |
| 250 | − | MirrorsOp::GetHandBackPlan => g1t_kit::call(integrations, "mirror_hand_back_plan", &act()).await, | |
| 251 | − | MirrorsOp::TakeOver => g1t_kit::call(integrations, "mirror_take_over", &act()).await, | |
| 252 | − | MirrorsOp::SyncMirror => g1t_kit::call(integrations, "mirror_sync", &act()).await, | |
| 253 | − | MirrorsOp::SetCiFailover => { | |
| 254 | − | let Some(on) = flag(&input["on"]) else { | |
| 255 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Say on: true to start CI failover, or false to end it.")); | |
| 256 | − | }; | |
| 257 | − | g1t_kit::call(integrations, "mirror_ci", &MirrorCiArgs { actor, repo_id: repo.id, on }).await | |
| 258 | − | } | |
| 259 | − | MirrorsOp::HandBack => { | |
| 260 | − | let decisions = match decisions_from(input) { | |
| 261 | − | Ok(decisions) => decisions, | |
| 262 | − | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 263 | − | }; | |
| 264 | − | g1t_kit::call(integrations, "mirror_hand_back", &MirrorHandBackArgs { actor, repo_id: repo.id, decisions }).await | |
| 265 | − | } | |
| 266 | − | MirrorsOp::MoveToG1t => { | |
| 267 | − | let keep_remote_updated = match &input["keep_remote_updated"] { | |
| 268 | − | Value::Null => false, | |
| 269 | − | value => match flag(value) { | |
| 270 | − | Some(keep) => keep, | |
| 271 | − | None => return Ok(Outcome::fail(FailureCode::Invalid, "keep_remote_updated is true or false.")), | |
| 272 | − | }, | |
| 273 | − | }; | |
| 274 | − | g1t_kit::call(integrations, "mirror_move_in", &MirrorMoveInArgs { actor, repo_id: repo.id, keep_remote_updated }).await | |
| 275 | − | } | |
| 276 | − | MirrorsOp::AddRemote => { | |
| 277 | − | let provider = match text(input, "provider").as_str() { | |
| 278 | − | "g1t" => RemoteProvider::G1t, | |
| 279 | − | "git" => RemoteProvider::Git, | |
| 280 | − | "github" => { | |
| 281 | − | return Ok(Outcome::fail( | |
| 282 | − | FailureCode::Invalid, | |
| 283 | − | "GitHub repositories are linked by importing them through g1t's GitHub App.", | |
| 284 | − | )); | |
| 285 | − | } | |
| 286 | − | _ => return Ok(Outcome::fail(FailureCode::Invalid, "provider is g1t or git.")), | |
| 287 | − | }; | |
| 288 | − | let role = match text(input, "role").as_str() { | |
| 289 | − | "leader" => RemoteRole::Leader, | |
| 290 | − | "follower" => RemoteRole::Follower, | |
| 291 | − | _ => return Ok(Outcome::fail(FailureCode::Invalid, "role is follower (g1t leads) or leader (the remote leads).")), | |
| 292 | − | }; | |
| 293 | − | let username = Some(text(input, "username")).filter(|name| !name.is_empty()); | |
| 294 | − | let token = Some(text(input, "token")).filter(|token| !token.is_empty()); | |
| 295 | − | let args = MirrorAddArgs { actor, repo_id: repo.id, provider, role, url: text(input, "url"), username, token }; | |
| 296 | − | g1t_kit::call(integrations, "mirror_add", &args).await | |
| 297 | − | } | |
| 298 | − | MirrorsOp::UpdateRemote => { | |
| 299 | − | let view: Outcome<g1t_contracts::mirrors::MirrorView> = | |
| 300 | − | g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?; | |
| 301 | − | let view = match view { | |
| 302 | − | Outcome::Ok(view) => view, | |
| 303 | − | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 304 | − | }; | |
| 305 | − | let Some(remote) = view.remotes.into_iter().find(|remote| remote.id == id) else { | |
| 306 | − | return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's.")); | |
| 307 | − | }; | |
| 308 | − | let settings = match settings_from(remote.settings, input) { | |
| 309 | − | Ok(settings) => settings, | |
| 310 | − | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 311 | − | }; | |
| 312 | − | g1t_kit::call(integrations, "mirror_settings", &MirrorSettingsArgs { actor, remote_id: id, settings }).await | |
| 313 | − | } | |
| 314 | − | MirrorsOp::RemoveRemote => { | |
| 315 | − | let view: Outcome<g1t_contracts::mirrors::MirrorView> = | |
| 316 | − | g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?; | |
| 317 | − | if !matches!(&view, Outcome::Ok(view) if view.remotes.iter().any(|remote| remote.id == id)) { | |
| 318 | − | return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's.")); | |
| 319 | − | } | |
| 320 | − | g1t_kit::call(integrations, "mirror_remove", &MirrorRemoveArgs { actor, remote_id: id }).await | |
| 321 | − | } | |
| 322 | − | } | |
| 323 | − | } | |
| 324 | − | ||
| 325 | − | #[cfg(test)] | |
| 326 | − | mod tests { | |
| 327 | − | use super::*; | |
| 328 | − | use g1t_contracts::credentials::NEVER; | |
| 329 | − | use g1t_contracts::mirrors::{HandBack, Notify, RemotePushes}; | |
| 330 | − | use g1t_contracts::scopes::{Level, scope_for}; | |
| 331 | − | ||
| 332 | − | #[test] | |
| 333 | − | fn each_operation_is_described_and_scoped() { | |
| 334 | − | for op in MirrorsOp::ALL { | |
| 335 | − | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Mirrors(op)), "{}", op.name()); | |
| 336 | − | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 337 | − | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 338 | − | let level = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())).level(); | |
| 339 | − | let expected = match op { | |
| 340 | − | MirrorsOp::GetMirror => Level::Read, | |
| 341 | − | MirrorsOp::SyncMirror => Level::Write, | |
| 342 | − | _ => Level::Admin, | |
| 343 | − | }; | |
| 344 | − | assert_eq!(level, expected, "{}", op.name()); | |
| 345 | − | } | |
| 346 | − | for op in [MirrorsOp::MoveToG1t, MirrorsOp::AddRemote, MirrorsOp::UpdateRemote, MirrorsOp::RemoveRemote] { | |
| 347 | − | assert!(NEVER.contains(&op.name()), "agents never {}", op.name()); | |
| 348 | − | } | |
| 349 | − | } | |
| 350 | − | ||
| 351 | − | #[test] | |
| 352 | − | fn settings_change_only_what_is_sent() { | |
| 353 | − | let base = MirrorSettings::default(); | |
| 354 | − | let changed = settings_from(base.clone(), &json!({ "notify": "inbox", "take_over_after": 30, "keep_ci_warm": "true" })).unwrap(); | |
| 355 | − | assert_eq!(changed.notify, Notify::Inbox); | |
| 356 | − | assert_eq!(changed.take_over_after, Some(30)); | |
| 357 | − | assert!(changed.keep_ci_warm); | |
| 358 | − | assert_eq!(changed.hand_back, HandBack::WhenClean); | |
| 359 | − | assert_eq!(changed.remote_pushes, RemotePushes::Adopt); | |
| 360 | − | assert!(changed.report_checks, "checks go to GitHub unless turned off"); | |
| 361 | − | assert!(!settings_from(base.clone(), &json!({ "report_checks": false })).unwrap().report_checks); | |
| 362 | − | let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap(); | |
| 363 | − | assert_eq!(cleared.take_over_after, None); | |
| 364 | − | assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err()); | |
| 365 | − | assert!(settings_from(base, &json!({ "hold_deploys": "maybe" })).is_err()); | |
| 366 | − | } | |
| 367 | − | ||
| 368 | − | #[test] | |
| 369 | − | fn decisions_name_refs_or_branches() { | |
| 370 | − | let decisions = decisions_from(&json!({ "decisions": { "docs": "keep_theirs", "refs/tags/v1": "keep_ours" } })).unwrap(); | |
| 371 | − | assert_eq!(decisions.get("refs/heads/docs"), Some(&RefDecision::KeepTheirs)); | |
| 372 | − | assert_eq!(decisions.get("refs/tags/v1"), Some(&RefDecision::KeepOurs)); | |
| 373 | − | assert!(decisions_from(&json!({ "decisions": { "docs": "merge" } })).is_err()); | |
| 374 | − | assert!(decisions_from(&json!({})).unwrap().is_empty()); | |
| 375 | − | } | |
| 376 | − | } |
| 12 | 12 | use crate::archives::ArchiveOp; | |
| 13 | 13 | use crate::customize::CustomizeOp; | |
| 14 | 14 | use crate::deploy_keys::DeployKeysOp; | |
| 15 | − | use crate::mirrors::MirrorsOp; | |
| 16 | 15 | use crate::deployments::DeploymentsOp; | |
| 17 | 16 | use crate::telemetry::TelemetryOp; | |
| 18 | 17 | use crate::packages::PackagesOp; | |
| ⋯ | |||
| 192 | 191 | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 193 | 192 | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 194 | 193 | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 195 | − | ], | |
| 196 | − | ), | |
| 197 | − | ( | |
| 198 | − | "Mirroring", | |
| 199 | − | "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.", | |
| 200 | − | &[ | |
| 201 | − | Op::Mirrors(MirrorsOp::GetMirror), | |
| 202 | − | Op::Mirrors(MirrorsOp::GetHandBackPlan), | |
| 203 | − | Op::Mirrors(MirrorsOp::TakeOver), | |
| 204 | − | Op::Mirrors(MirrorsOp::SetCiFailover), | |
| 205 | − | Op::Mirrors(MirrorsOp::HandBack), | |
| 206 | − | Op::Mirrors(MirrorsOp::MoveToG1t), | |
| 207 | − | Op::Mirrors(MirrorsOp::SyncMirror), | |
| 208 | − | Op::Mirrors(MirrorsOp::AddRemote), | |
| 209 | − | Op::Mirrors(MirrorsOp::UpdateRemote), | |
| 210 | − | Op::Mirrors(MirrorsOp::RemoveRemote), | |
| 211 | 194 | ], | |
| 212 | 195 | ), | |
| 213 | 196 | ( | |
| ⋯ | |||
| 873 | 856 | Op::Artifacts(op) => op.title(), | |
| 874 | 857 | Op::Archives(op) => op.title(), | |
| 875 | 858 | Op::DeployKeys(op) => op.title(), | |
| 876 | − | Op::Mirrors(op) => op.title(), | |
| 877 | 859 | Op::Packages(op) => op.title(), | |
| 878 | 860 | Op::Folios(op) => op.title(), | |
| 879 | 861 | Op::Customize(op) => op.title(), | |
| 32 | 32 | use crate::archives::ArchiveOp; | |
| 33 | 33 | use crate::customize::CustomizeOp; | |
| 34 | 34 | use crate::deploy_keys::DeployKeysOp; | |
| 35 | − | use crate::mirrors::MirrorsOp; | |
| 36 | 35 | use crate::deployments::DeploymentsOp; | |
| 37 | 36 | use crate::telemetry::TelemetryOp; | |
| 38 | 37 | use crate::packages::PackagesOp; | |
| ⋯ | |||
| 343 | 342 | Archives(ArchiveOp), | |
| 344 | 343 | /// A repository's deploy keys: deploy_keys.rs. | |
| 345 | 344 | DeployKeys(DeployKeysOp), | |
| 346 | − | /// A repository's mirroring: its remotes, takeovers and hand-backs: | |
| 347 | − | /// mirrors.rs. | |
| 348 | − | Mirrors(MirrorsOp), | |
| 349 | 345 | /// A workspace's packages, their versions, deleting and restoring | |
| 350 | 346 | /// them, and who may use them: packages.rs. | |
| 351 | 347 | Packages(PackagesOp), | |
| ⋯ | |||
| 724 | 720 | } | |
| 725 | 721 | ||
| 726 | 722 | impl Op { | |
| 727 | − | pub const ALL: [Op; 408] = [ | |
| 723 | + | pub const ALL: [Op; 398] = [ | |
| 728 | 724 | Op::Whoami, | |
| 729 | 725 | Op::GetWorkspace, | |
| 730 | 726 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1065 | 1061 | Op::Customize(CustomizeOp::GrantConnector), | |
| 1066 | 1062 | Op::Customize(CustomizeOp::RevokeConnector), | |
| 1067 | 1063 | Op::Customize(CustomizeOp::Bundle), | |
| 1068 | − | Op::Mirrors(MirrorsOp::GetMirror), | |
| 1069 | − | Op::Mirrors(MirrorsOp::GetHandBackPlan), | |
| 1070 | − | Op::Mirrors(MirrorsOp::TakeOver), | |
| 1071 | − | Op::Mirrors(MirrorsOp::SetCiFailover), | |
| 1072 | − | Op::Mirrors(MirrorsOp::HandBack), | |
| 1073 | − | Op::Mirrors(MirrorsOp::MoveToG1t), | |
| 1074 | − | Op::Mirrors(MirrorsOp::SyncMirror), | |
| 1075 | − | Op::Mirrors(MirrorsOp::AddRemote), | |
| 1076 | − | Op::Mirrors(MirrorsOp::UpdateRemote), | |
| 1077 | − | Op::Mirrors(MirrorsOp::RemoveRemote), | |
| 1078 | 1064 | Op::Protection(ProtectionOp::UpdateEnvironment), | |
| 1079 | 1065 | Op::Protection(ProtectionOp::DeleteEnvironment), | |
| 1080 | 1066 | Op::Protection(ProtectionOp::GetPendingDeployments), | |
| ⋯ | |||
| 1352 | 1338 | Op::Artifacts(op) => op.name(), | |
| 1353 | 1339 | Op::Archives(op) => op.name(), | |
| 1354 | 1340 | Op::DeployKeys(op) => op.name(), | |
| 1355 | − | Op::Mirrors(op) => op.name(), | |
| 1356 | 1341 | Op::Packages(op) => op.name(), | |
| 1357 | 1342 | Op::Folios(op) => op.name(), | |
| 1358 | 1343 | Op::Customize(op) => op.name(), | |
| ⋯ | |||
| 1933 | 1918 | Op::Artifacts(op) => op.description(), | |
| 1934 | 1919 | Op::Archives(op) => op.description(), | |
| 1935 | 1920 | Op::DeployKeys(op) => op.description(), | |
| 1936 | − | Op::Mirrors(op) => op.description(), | |
| 1937 | 1921 | Op::Packages(op) => op.description(), | |
| 1938 | 1922 | Op::Folios(op) => op.description(), | |
| 1939 | 1923 | Op::Customize(op) => op.description(), | |
| ⋯ | |||
| 3457 | 3441 | Op::Artifacts(op) => op.input(), | |
| 3458 | 3442 | Op::Archives(op) => op.input(), | |
| 3459 | 3443 | Op::DeployKeys(op) => op.input(), | |
| 3460 | − | Op::Mirrors(op) => op.input(), | |
| 3461 | 3444 | Op::Packages(op) => op.input(), | |
| 3462 | 3445 | Op::Folios(op) => op.input(), | |
| 3463 | 3446 | Op::Customize(op) => op.input(), | |
| ⋯ | |||
| 4626 | 4609 | is_private: input["private"].as_bool() == Some(true), | |
| 4627 | 4610 | import_url: optional_text(input, "import_url"), | |
| 4628 | 4611 | import_token: None, | |
| 4629 | − | mirror: None, | |
| 4630 | 4612 | }, | |
| 4631 | 4613 | ) | |
| 4632 | 4614 | .await | |
| ⋯ | |||
| 5872 | 5854 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 5873 | 5855 | Op::Archives(op) => crate::archives::run(op, services, viewer, input).await, | |
| 5874 | 5856 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5875 | − | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5876 | 5857 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5877 | 5858 | Op::Folios(op) => crate::folios::run(op, services, viewer, input).await, | |
| 5878 | 5859 | Op::Customize(op) => crate::customize::run(op, services, viewer, input).await, | |
| 13861 | 13861 | ] | |
| 13862 | 13862 | } | |
| 13863 | 13863 | }, | |
| 13864 | − | "get_mirror": { | |
| 13865 | − | "params": { | |
| 13866 | − | "owner": "acme", | |
| 13867 | − | "name": "web" | |
| 13868 | − | }, | |
| 13869 | − | "response": { | |
| 13870 | − | "remotes": [ | |
| 13871 | − | { | |
| 13872 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 13873 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 13874 | − | "repo": "acme/web", | |
| 13875 | − | "provider": "github", | |
| 13876 | − | "role": "leader", | |
| 13877 | − | "name": "github.com/acme/web", | |
| 13878 | − | "url": "https://github.com/acme/web", | |
| 13879 | − | "state": "standby", | |
| 13880 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 13881 | − | "state_by": "syntaqx", | |
| 13882 | − | "reachable": true, | |
| 13883 | − | "unreachable_since": null, | |
| 13884 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 13885 | − | "last_error": null, | |
| 13886 | − | "checks_problem": null, | |
| 13887 | − | "settings": { | |
| 13888 | − | "notify": "banner", | |
| 13889 | − | "take_over_after": null, | |
| 13890 | − | "hand_back": "when_clean", | |
| 13891 | − | "keep_ci_warm": false, | |
| 13892 | − | "github_workflows": true, | |
| 13893 | − | "hold_deploys": true, | |
| 13894 | − | "report_checks": true, | |
| 13895 | − | "remote_pushes": "adopt" | |
| 13896 | − | }, | |
| 13897 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 13898 | − | } | |
| 13899 | − | ], | |
| 13900 | − | "plan": null, | |
| 13901 | − | "can_manage": true | |
| 13902 | − | }, | |
| 13903 | − | "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." | |
| 13904 | − | }, | |
| 13905 | − | "get_hand_back_plan": { | |
| 13906 | − | "params": { | |
| 13907 | − | "owner": "acme", | |
| 13908 | − | "name": "web" | |
| 13909 | − | }, | |
| 13910 | − | "response": { | |
| 13911 | − | "remotes": [ | |
| 13912 | − | { | |
| 13913 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 13914 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 13915 | − | "repo": "acme/web", | |
| 13916 | − | "provider": "github", | |
| 13917 | − | "role": "leader", | |
| 13918 | − | "name": "github.com/acme/web", | |
| 13919 | − | "url": "https://github.com/acme/web", | |
| 13920 | − | "state": "takeover", | |
| 13921 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 13922 | − | "state_by": "syntaqx", | |
| 13923 | − | "reachable": true, | |
| 13924 | − | "unreachable_since": null, | |
| 13925 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 13926 | − | "last_error": null, | |
| 13927 | − | "checks_problem": null, | |
| 13928 | − | "settings": { | |
| 13929 | − | "notify": "banner", | |
| 13930 | − | "take_over_after": null, | |
| 13931 | − | "hand_back": "when_clean", | |
| 13932 | − | "keep_ci_warm": false, | |
| 13933 | − | "github_workflows": true, | |
| 13934 | − | "hold_deploys": true, | |
| 13935 | − | "report_checks": true, | |
| 13936 | − | "remote_pushes": "adopt" | |
| 13937 | − | }, | |
| 13938 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 13939 | − | } | |
| 13940 | − | ], | |
| 13941 | − | "plan": { | |
| 13942 | − | "refs": [ | |
| 13943 | − | { | |
| 13944 | − | "ref": "refs/heads/fix/login-timeout", | |
| 13945 | − | "base": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4", | |
| 13946 | − | "ours": "c47f2b8e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a", | |
| 13947 | − | "theirs": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4", | |
| 13948 | − | "action": "push", | |
| 13949 | − | "decision": null | |
| 13950 | − | }, | |
| 13951 | − | { | |
| 13952 | − | "ref": "refs/heads/main", | |
| 13953 | − | "base": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d", | |
| 13954 | − | "ours": "e01d9a37f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1", | |
| 13955 | − | "theirs": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d", | |
| 13956 | − | "action": "pull_request", | |
| 13957 | − | "decision": null | |
| 13958 | − | }, | |
| 13959 | − | { | |
| 13960 | − | "ref": "refs/heads/docs/readme", | |
| 13961 | − | "base": "7f3a1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b", | |
| 13962 | − | "ours": "5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c", | |
| 13963 | − | "theirs": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 13964 | − | "action": "diverged", | |
| 13965 | − | "decision": null | |
| 13966 | − | } | |
| 13967 | − | ], | |
| 13968 | − | "reachable": true, | |
| 13969 | − | "ready": false | |
| 13970 | − | }, | |
| 13971 | − | "can_manage": true | |
| 13972 | − | }, | |
| 13973 | − | "notes": "Refs that did not move on either side are left out. `ready` stays false until `docs/readme` has a decision." | |
| 13974 | − | }, | |
| 13975 | − | "take_over_mirror": { | |
| 13976 | − | "params": { | |
| 13977 | − | "owner": "acme", | |
| 13978 | − | "name": "web" | |
| 13979 | − | }, | |
| 13980 | − | "response": { | |
| 13981 | − | "remotes": [ | |
| 13982 | − | { | |
| 13983 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 13984 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 13985 | − | "repo": "acme/web", | |
| 13986 | − | "provider": "github", | |
| 13987 | − | "role": "leader", | |
| 13988 | − | "name": "github.com/acme/web", | |
| 13989 | − | "url": "https://github.com/acme/web", | |
| 13990 | − | "state": "takeover", | |
| 13991 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 13992 | − | "state_by": "syntaqx", | |
| 13993 | − | "reachable": false, | |
| 13994 | − | "unreachable_since": "2026-10-08T14:04:00.000Z", | |
| 13995 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 13996 | − | "last_error": null, | |
| 13997 | − | "checks_problem": null, | |
| 13998 | − | "settings": { | |
| 13999 | − | "notify": "banner", | |
| 14000 | − | "take_over_after": null, | |
| 14001 | − | "hand_back": "when_clean", | |
| 14002 | − | "keep_ci_warm": false, | |
| 14003 | − | "github_workflows": true, | |
| 14004 | − | "hold_deploys": true, | |
| 14005 | − | "report_checks": true, | |
| 14006 | − | "remote_pushes": "adopt" | |
| 14007 | − | }, | |
| 14008 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14009 | − | } | |
| 14010 | − | ], | |
| 14011 | − | "plan": null, | |
| 14012 | − | "can_manage": true | |
| 14013 | − | } | |
| 14014 | − | }, | |
| 14015 | − | "set_ci_failover": { | |
| 14016 | − | "params": { | |
| 14017 | − | "owner": "acme", | |
| 14018 | − | "name": "web" | |
| 14019 | − | }, | |
| 14020 | − | "request": { | |
| 14021 | − | "on": true | |
| 14022 | − | }, | |
| 14023 | − | "response": { | |
| 14024 | − | "remotes": [ | |
| 14025 | − | { | |
| 14026 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 14027 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 14028 | − | "repo": "acme/web", | |
| 14029 | − | "provider": "github", | |
| 14030 | − | "role": "leader", | |
| 14031 | − | "name": "github.com/acme/web", | |
| 14032 | − | "url": "https://github.com/acme/web", | |
| 14033 | − | "state": "ci", | |
| 14034 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 14035 | − | "state_by": "syntaqx", | |
| 14036 | − | "reachable": true, | |
| 14037 | − | "unreachable_since": null, | |
| 14038 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 14039 | − | "last_error": null, | |
| 14040 | − | "checks_problem": null, | |
| 14041 | − | "settings": { | |
| 14042 | − | "notify": "banner", | |
| 14043 | − | "take_over_after": null, | |
| 14044 | − | "hand_back": "when_clean", | |
| 14045 | − | "keep_ci_warm": false, | |
| 14046 | − | "github_workflows": true, | |
| 14047 | − | "hold_deploys": true, | |
| 14048 | − | "report_checks": true, | |
| 14049 | − | "remote_pushes": "adopt" | |
| 14050 | − | }, | |
| 14051 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14052 | − | } | |
| 14053 | − | ], | |
| 14054 | − | "plan": null, | |
| 14055 | − | "can_manage": true | |
| 14056 | − | } | |
| 14057 | − | }, | |
| 14058 | − | "hand_back_mirror": { | |
| 14059 | − | "params": { | |
| 14060 | − | "owner": "acme", | |
| 14061 | − | "name": "web" | |
| 14062 | − | }, | |
| 14063 | − | "request": { | |
| 14064 | − | "decisions": { | |
| 14065 | − | "refs/heads/docs/readme": "pull_request" | |
| 14066 | − | } | |
| 14067 | − | }, | |
| 14068 | − | "response": { | |
| 14069 | − | "remotes": [ | |
| 14070 | − | { | |
| 14071 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 14072 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 14073 | − | "repo": "acme/web", | |
| 14074 | − | "provider": "github", | |
| 14075 | − | "role": "leader", | |
| 14076 | − | "name": "github.com/acme/web", | |
| 14077 | − | "url": "https://github.com/acme/web", | |
| 14078 | − | "state": "standby", | |
| 14079 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 14080 | − | "state_by": "syntaqx", | |
| 14081 | − | "reachable": true, | |
| 14082 | − | "unreachable_since": null, | |
| 14083 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 14084 | − | "last_error": null, | |
| 14085 | − | "checks_problem": null, | |
| 14086 | − | "settings": { | |
| 14087 | − | "notify": "banner", | |
| 14088 | − | "take_over_after": null, | |
| 14089 | − | "hand_back": "when_clean", | |
| 14090 | − | "keep_ci_warm": false, | |
| 14091 | − | "github_workflows": true, | |
| 14092 | − | "hold_deploys": true, | |
| 14093 | − | "report_checks": true, | |
| 14094 | − | "remote_pushes": "adopt" | |
| 14095 | − | }, | |
| 14096 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14097 | − | } | |
| 14098 | − | ], | |
| 14099 | − | "plan": null, | |
| 14100 | − | "can_manage": true, | |
| 14101 | − | "notes": [ | |
| 14102 | − | "main: opened https://github.com/acme/web/pull/1290", | |
| 14103 | − | "docs/readme: opened https://github.com/acme/web/pull/1291" | |
| 14104 | − | ] | |
| 14105 | − | }, | |
| 14106 | − | "notes": "Branch names work as keys too: `{ \"docs/readme\": \"keep_theirs\" }`." | |
| 14107 | − | }, | |
| 14108 | − | "move_mirror_to_g1t": { | |
| 14109 | − | "params": { | |
| 14110 | − | "owner": "acme", | |
| 14111 | − | "name": "web" | |
| 14112 | − | }, | |
| 14113 | − | "request": { | |
| 14114 | − | "keep_remote_updated": false | |
| 14115 | − | }, | |
| 14116 | − | "response": { | |
| 14117 | − | "remotes": [], | |
| 14118 | − | "plan": null, | |
| 14119 | − | "can_manage": true | |
| 14120 | − | }, | |
| 14121 | − | "notes": "With `keep_remote_updated: true` the remote stays in `remotes` as a follower, and g1t pushes to it from then on." | |
| 14122 | − | }, | |
| 14123 | − | "sync_mirror": { | |
| 14124 | − | "params": { | |
| 14125 | − | "owner": "acme", | |
| 14126 | − | "name": "web" | |
| 14127 | − | }, | |
| 14128 | − | "response": { | |
| 14129 | − | "remotes": [ | |
| 14130 | − | { | |
| 14131 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 14132 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 14133 | − | "repo": "acme/web", | |
| 14134 | − | "provider": "github", | |
| 14135 | − | "role": "leader", | |
| 14136 | − | "name": "github.com/acme/web", | |
| 14137 | − | "url": "https://github.com/acme/web", | |
| 14138 | − | "state": "standby", | |
| 14139 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 14140 | − | "state_by": "syntaqx", | |
| 14141 | − | "reachable": true, | |
| 14142 | − | "unreachable_since": null, | |
| 14143 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 14144 | − | "last_error": null, | |
| 14145 | − | "checks_problem": null, | |
| 14146 | − | "settings": { | |
| 14147 | − | "notify": "banner", | |
| 14148 | − | "take_over_after": null, | |
| 14149 | − | "hand_back": "when_clean", | |
| 14150 | − | "keep_ci_warm": false, | |
| 14151 | − | "github_workflows": true, | |
| 14152 | − | "hold_deploys": true, | |
| 14153 | − | "report_checks": true, | |
| 14154 | − | "remote_pushes": "adopt" | |
| 14155 | − | }, | |
| 14156 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14157 | − | } | |
| 14158 | − | ], | |
| 14159 | − | "plan": null, | |
| 14160 | − | "can_manage": true | |
| 14161 | − | } | |
| 14162 | − | }, | |
| 14163 | − | "add_mirror_remote": { | |
| 14164 | − | "params": { | |
| 14165 | − | "owner": "acme", | |
| 14166 | − | "name": "web" | |
| 14167 | − | }, | |
| 14168 | − | "request": { | |
| 14169 | − | "provider": "g1t", | |
| 14170 | − | "role": "follower", | |
| 14171 | − | "url": "https://git.acme.internal/acme/web.git", | |
| 14172 | − | "username": "deploy", | |
| 14173 | − | "token": "g1t_…" | |
| 14174 | − | }, | |
| 14175 | − | "response": { | |
| 14176 | − | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n", | |
| 14177 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 14178 | − | "repo": "acme/web", | |
| 14179 | − | "provider": "g1t", | |
| 14180 | − | "role": "follower", | |
| 14181 | − | "name": "git.acme.internal/acme/web", | |
| 14182 | − | "url": "https://git.acme.internal/acme/web", | |
| 14183 | − | "state": "following", | |
| 14184 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 14185 | − | "state_by": "syntaqx", | |
| 14186 | − | "reachable": true, | |
| 14187 | − | "unreachable_since": null, | |
| 14188 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 14189 | − | "last_error": null, | |
| 14190 | − | "checks_problem": null, | |
| 14191 | − | "settings": { | |
| 14192 | − | "notify": "banner", | |
| 14193 | − | "take_over_after": null, | |
| 14194 | − | "hand_back": "when_clean", | |
| 14195 | − | "keep_ci_warm": false, | |
| 14196 | − | "github_workflows": true, | |
| 14197 | − | "hold_deploys": true, | |
| 14198 | − | "report_checks": true, | |
| 14199 | − | "remote_pushes": "adopt" | |
| 14200 | − | }, | |
| 14201 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14202 | − | }, | |
| 14203 | − | "notes": "The token is never returned. A remote that refused it, or could not be reached, is still added, with `last_error` saying why." | |
| 14204 | − | }, | |
| 14205 | − | "update_mirror_remote": { | |
| 14206 | − | "params": { | |
| 14207 | − | "owner": "acme", | |
| 14208 | − | "name": "web", | |
| 14209 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m" | |
| 14210 | − | }, | |
| 14211 | − | "request": { | |
| 14212 | − | "notify": "inbox", | |
| 14213 | − | "take_over_after": 30 | |
| 14214 | − | }, | |
| 14215 | − | "response": { | |
| 14216 | − | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 14217 | − | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 14218 | − | "repo": "acme/web", | |
| 14219 | − | "provider": "github", | |
| 14220 | − | "role": "leader", | |
| 14221 | − | "name": "github.com/acme/web", | |
| 14222 | − | "url": "https://github.com/acme/web", | |
| 14223 | − | "state": "standby", | |
| 14224 | − | "state_since": "2026-10-08T13:58:00.000Z", | |
| 14225 | − | "state_by": "syntaqx", | |
| 14226 | − | "reachable": true, | |
| 14227 | − | "unreachable_since": null, | |
| 14228 | − | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 14229 | − | "last_error": null, | |
| 14230 | − | "checks_problem": null, | |
| 14231 | − | "settings": { | |
| 14232 | − | "notify": "inbox", | |
| 14233 | − | "take_over_after": 30, | |
| 14234 | − | "hand_back": "when_clean", | |
| 14235 | − | "keep_ci_warm": false, | |
| 14236 | − | "github_workflows": true, | |
| 14237 | − | "hold_deploys": true, | |
| 14238 | − | "report_checks": true, | |
| 14239 | − | "remote_pushes": "adopt" | |
| 14240 | − | }, | |
| 14241 | − | "created_at": "2026-10-05T10:00:00.000Z" | |
| 14242 | − | } | |
| 14243 | − | }, | |
| 14244 | − | "remove_mirror_remote": { | |
| 14245 | − | "params": { | |
| 14246 | − | "owner": "acme", | |
| 14247 | − | "name": "web", | |
| 14248 | − | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n" | |
| 14249 | − | }, | |
| 14250 | − | "response": true | |
| 14251 | − | }, | |
| 14252 | 13864 | "list_workspace_artifacts": { | |
| 14253 | 13865 | "params": { | |
| 14254 | 13866 | "workspace": "acme" |
| 7 | 7 | use crate::archives::ArchiveOp; | |
| 8 | 8 | use crate::customize::CustomizeOp; | |
| 9 | 9 | use crate::deploy_keys::DeployKeysOp; | |
| 10 | − | use crate::mirrors::MirrorsOp; | |
| 11 | 10 | use crate::deployments::DeploymentsOp; | |
| 12 | 11 | use crate::telemetry::TelemetryOp; | |
| 13 | 12 | use crate::packages::PackagesOp; | |
| ⋯ | |||
| 89 | 88 | route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]), | |
| 90 | 89 | route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]), | |
| 91 | 90 | route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]), | |
| 92 | − | // Mirroring: a repository's remotes, takeovers and hand-backs. | |
| 93 | − | route("GET", "/repos/:owner/:name/mirror", Op::Mirrors(MirrorsOp::GetMirror), &[]), | |
| 94 | − | route("GET", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::GetHandBackPlan), &[]), | |
| 95 | − | route("POST", "/repos/:owner/:name/mirror/take-over", Op::Mirrors(MirrorsOp::TakeOver), &[]), | |
| 96 | − | route("POST", "/repos/:owner/:name/mirror/ci", Op::Mirrors(MirrorsOp::SetCiFailover), &[]), | |
| 97 | − | route("POST", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::HandBack), &[]), | |
| 98 | − | route("POST", "/repos/:owner/:name/mirror/move-to-g1t", Op::Mirrors(MirrorsOp::MoveToG1t), &[]), | |
| 99 | − | route("POST", "/repos/:owner/:name/mirror/sync", Op::Mirrors(MirrorsOp::SyncMirror), &[]), | |
| 100 | − | route("POST", "/repos/:owner/:name/mirror/remotes", Op::Mirrors(MirrorsOp::AddRemote), &[]), | |
| 101 | − | route("PATCH", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::UpdateRemote), &[]), | |
| 102 | − | route("DELETE", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::RemoveRemote), &[]), | |
| 103 | 91 | // Your notifications: threads, marking them, and what you subscribe | |
| 104 | 92 | // to and watch. GitHub's addresses, with g1t's saved and snoozed. | |
| 105 | 93 | 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")]), | |
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | 627 | /// The repository a project's permission comes from, as the deployments | |
| 628 | − | /// service reads it: a mirrored project has none, and its workspace's base | |
| 629 | − | /// permission decides. | |
| 628 | + | /// service reads it: a project without a hosted source has none, and its | |
| 629 | + | /// workspace's base permission decides. | |
| 630 | 630 | fn repo_of(project: &Value) -> (String, String, bool) { | |
| 631 | 631 | let source = &project["source"]; | |
| 632 | 632 | let private = project["private"].as_bool().unwrap_or(true); |
| 23 | 23 | use crate::archives::ArchiveOp; | |
| 24 | 24 | use crate::customize::CustomizeOp; | |
| 25 | 25 | use crate::deploy_keys::DeployKeysOp; | |
| 26 | − | use crate::mirrors::MirrorsOp; | |
| 27 | 26 | use crate::deployments::DeploymentsOp; | |
| 28 | 27 | use crate::telemetry::TelemetryOp; | |
| 29 | 28 | use crate::packages::PackagesOp; | |
| ⋯ | |||
| 72 | 71 | Tool { | |
| 73 | 72 | name: "repository", | |
| 74 | 73 | title: "Repositories", | |
| 75 | − | 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`.", | |
| 74 | + | 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`.", | |
| 76 | 75 | default_action: None, | |
| 77 | 76 | actions: &[ | |
| 78 | 77 | a("list", Op::ListRepos, "Repositories you can see"), | |
| ⋯ | |||
| 123 | 122 | a("delete_branch", Op::DeleteBranch, "Delete a branch; make it again at its commit to restore it"), | |
| 124 | 123 | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 125 | 124 | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 126 | − | a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"), | |
| 127 | − | a("mirror_hand_back_plan", Op::Mirrors(MirrorsOp::GetHandBackPlan), "What handing a takeover back would do, ref by ref"), | |
| 128 | − | a("mirror_take_over", Op::Mirrors(MirrorsOp::TakeOver), "Make g1t lead a mirror for now"), | |
| 129 | − | a("mirror_ci", Op::Mirrors(MirrorsOp::SetCiFailover), "Run a mirror's workflows on g1t (on), or stop (off)"), | |
| 130 | − | a("mirror_hand_back", Op::Mirrors(MirrorsOp::HandBack), "Send a takeover back, deciding diverged refs"), | |
| 131 | − | a("mirror_move_to_g1t", Op::Mirrors(MirrorsOp::MoveToG1t), "Stop tracking the remote; g1t leads for good"), | |
| 132 | − | a("mirror_sync", Op::Mirrors(MirrorsOp::SyncMirror), "Bring its remotes in step now"), | |
| 133 | − | a("mirror_add_remote", Op::Mirrors(MirrorsOp::AddRemote), "Link another g1t or git host"), | |
| 134 | − | a("mirror_update_remote", Op::Mirrors(MirrorsOp::UpdateRemote), "Change a remote's settings"), | |
| 135 | − | a("mirror_remove_remote", Op::Mirrors(MirrorsOp::RemoveRemote), "Unlink a remote"), | |
| 136 | 125 | a("archive", Op::ArchiveRepo, "Make it read-only"), | |
| 137 | 126 | a("unarchive", Op::UnarchiveRepo, "Make it writable again"), | |
| 138 | 127 | a("set_visibility", Op::SetRepoVisibility, "Make it public or private"), | |
| 14 | 14 | '/guides/g1t-agents/': '/guides/working-with-g1t/', | |
| 15 | 15 | '/guides/inbox/': '/guides/notifications/', | |
| 16 | 16 | '/guides/marketplace/': '/guides/customize/', | |
| 17 | + | '/guides/mirroring/': '/guides/github/', | |
| 17 | 18 | '/guides/today/': '/guides/home/', | |
| 18 | 19 | }, | |
| 19 | 20 | integrations: [ | |
| ⋯ | |||
| 181 | 182 | items: [ | |
| 182 | 183 | { label: 'Accounts and sign-in', slug: 'guides/authentication' }, | |
| 183 | 184 | { label: 'GitHub', slug: 'guides/github' }, | |
| 184 | − | { label: 'Mirroring', slug: 'guides/mirroring' }, | |
| 185 | 185 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 186 | 186 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 187 | 187 | { label: 'Permissions', slug: 'guides/permissions' }, | |
| 59 | 59 | `main`. Each push sends only what the last did not. | |
| 60 | 60 | - **Status.** Depends on Cloudflare. | |
| 61 | 61 | ||
| 62 | − | ### Imports and mirrors up to 40 MB | |
| 62 | + | ### Imports up to 40 MB | |
| 63 | 63 | ||
| 64 | − | Importing or mirroring a repository copies it in one piece of at most | |
| 65 | − | 40 MB, after compression. | |
| 64 | + | Importing a repository copies it in one piece of at most 40 MB, after | |
| 65 | + | compression. | |
| 66 | 66 | ||
| 67 | 67 | - **Why.** The copy is held in memory while it moves, and a Worker has | |
| 68 | 68 | 128 MB for everything it is doing at once. | |
| 69 | 69 | - **Instead.** Clone the repository yourself and push it to g1t, in steps if | |
| 70 | − | it is over 100 MB. See [Import, mirror or move a repository](/guides/github/#what-comes-across). | |
| 70 | + | it is over 100 MB. See [Import a repository](/guides/github/#what-comes-across). | |
| 71 | 71 | - **Status.** Streaming the copy, so size stops mattering, is planned. | |
| 72 | 72 | ||
| 73 | 73 | ### Partial clone works, but is not promised |
| 110 | 110 | ||
| 111 | 111 | A per-workspace operation counter that is our best guess at your invoice. A | |
| 112 | 112 | smart HTTP | |
| 113 | − | client inside a Worker for landing, catch-up, mirrors and imports. Our own | |
| 113 | + | client inside a Worker for landing, catch-up and imports. Our own | |
| 114 | 114 | push policy in front of Artifacts. A versioned ref cache with a test that | |
| 115 | 115 | guards it. Two layers of credential caching. A bookmark protocol for D1. | |
| 116 | 116 | A probe that deploys throwaway Workers to measure placement, and a |
| 22 | 22 | Otherwise g1t doesn't read `.github`, so a repository that lives in both | |
| 23 | 23 | places can keep `.github` for GitHub and `.g1t` for g1t, side by side. | |
| 24 | 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. | |
| 44 | − | ||
| 45 | − | Whenever g1t runs a mirror's workflows, their results show on the commit on | |
| 46 | − | GitHub too, as commit statuses named `g1t / <workflow> / <event>`, such as | |
| 47 | − | `g1t / CI / push`. This needs the Commit statuses permission on g1t's GitHub | |
| 48 | − | App; turn it off with **Report checks to GitHub** in the mirror's settings. | |
| 49 | − | See [Checks on GitHub](/guides/mirroring/#checks-on-github). | |
| 50 | − | ||
| 51 | 25 | Then add your [secrets and variables](#secrets-and-variables): GitHub never | |
| 52 | 26 | gives their values out, so they cannot be copied across. | |
| 53 | 27 |
| 7 | 7 | for every project until you turn them on. Once they are on, every branch | |
| 8 | 8 | with a pull request gets its own live preview, linked on the pull request, | |
| 9 | 9 | and the default branch goes to production on every push. Reviewers, and the agents reviewing for you, | |
| 10 | − | click through the change instead of reading a diff. A [mirror](/guides/mirroring/) | |
| 11 | − | deploys nothing while the remote leads it: pushes copied in from the remote | |
| 12 | − | don't build, and its **Deployments** page comes with a takeover. | |
| 10 | + | click through the change instead of reading a diff. | |
| 13 | 11 | ||
| 14 | 12 | Apps run on Cloudflare Workers, on `g1t.page`, and a project with a | |
| 15 | 13 | `Dockerfile` (or a server g1t can write one for) runs as a container on |
| 230 | 230 | people with the Admin role see a note saying so, with **Protect this | |
| 231 | 231 | branch**, which starts a [ruleset](/guides/rules/), and **Dismiss**, which | |
| 232 | 232 | this browser remembers for them and that repository. An archived | |
| 233 | − | repository, or a mirror that g1t has not taken over, offers none of these | |
| 234 | − | changes. | |
| 233 | + | repository offers none of these changes. | |
| 235 | 234 | ||
| 236 | 235 | From the API, a person's token with `repo:write` can do the same: | |
| 237 | 236 | ||
| ⋯ | |||
| 342 | 341 | ### Pushes of many branches or tags | |
| 343 | 342 | ||
| 344 | 343 | Every branch and tag in a push is stored. Each one is also announced as a | |
| 345 | − | `git.push` event, which starts workflows, mirrors the repository and | |
| 346 | − | calls webhooks, except in a push of many: | |
| 344 | + | `git.push` event, which starts workflows and calls webhooks, except in a | |
| 345 | + | push of many: | |
| 347 | 346 | ||
| 348 | 347 | | A push of | What is announced | | |
| 349 | 348 | | --- | --- | | |
| 1 | 1 | --- | |
| 2 | 2 | title: GitHub | |
| 3 | − | description: Sign in with GitHub, link your account, and import, mirror or move repositories from GitHub through g1t's GitHub App. | |
| 3 | + | description: Sign in with GitHub, link your account, and import repositories from GitHub through g1t's GitHub App. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | g1t has one GitHub App, [g1t-sh](https://github.com/apps/g1t-sh). It does two | |
| ⋯ | |||
| 67 | 67 | workspace you belong to, as `github.sign_in`, `github.linked` and | |
| 68 | 68 | `github.unlinked`. | |
| 69 | 69 | ||
| 70 | − | ## Import, mirror or move a repository | |
| 70 | + | ## Import a repository | |
| 71 | 71 | ||
| 72 | 72 | 1. Choose **New project**, then **Import from GitHub**. You can also go to | |
| 73 | 73 | `g1t.sh/new/github`. | |
| ⋯ | |||
| 80 | 80 | [already installed the app on GitHub](#already-installed-the-app-on-github). | |
| 81 | 81 | 4. Tick the repositories to bring across. You see only repositories that | |
| 82 | 82 | both you and the app can reach. | |
| 83 | − | 5. Choose how they come across, and whether to copy their issues. | |
| 83 | + | 5. Choose whether to copy their issues. | |
| 84 | 84 | 6. Choose **Bring to g1t**. | |
| 85 | 85 | ||
| 86 | 86 | Each repository becomes a repository on g1t with the same name, private if | |
| 87 | − | it is private on GitHub, and a [project](/guides/projects/) of its own. | |
| 88 | − | ||
| 89 | − | | Choice | What happens | Where you push | | |
| 90 | − | | --- | --- | --- | | |
| 91 | − | | **Import** | Copied once. The g1t repository is then its own. | g1t | | |
| 92 | − | | **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 | | |
| 93 | − | | **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 | | |
| 94 | − | ||
| 95 | − | A standby mirror is a backup you can switch to: if GitHub is down, or | |
| 96 | − | whenever you want to work on g1t for a while, **take over** in its | |
| 97 | − | **Settings → Mirroring**, and **hand back** when you're done. You can also | |
| 98 | − | run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t | |
| 99 | − | for good. See [mirroring](/guides/mirroring/). | |
| 87 | + | it is private on GitHub, and a [project](/guides/projects/) of its own. It | |
| 88 | + | is copied once: the g1t repository is then its own, and you push to g1t. | |
| 89 | + | Nothing on GitHub changes. | |
| 100 | 90 | ||
| 101 | 91 | ### Already installed the app on GitHub? | |
| 102 | 92 | ||
| ⋯ | |||
| 131 | 121 | | Issue numbers | New numbers on g1t; each issue links to the original | | |
| 132 | 122 | | Comments on issues | No | | |
| 133 | 123 | | Pull requests | No. Their branches come across; open pull requests stay on GitHub. | | |
| 134 | − | | 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/). | | |
| 124 | + | | GitHub Actions workflows | Yes, as files. Rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). | | |
| 135 | 125 | | Releases, wikis, Git LFS objects | No | | |
| 136 | 126 | ||
| 137 | 127 | A repository is copied in one piece of at most 40 MB, after compression. | |
| 138 | 128 | Push a larger one with git instead: see [git](/guides/git/). | |
| 139 | 129 | ||
| 140 | − | ### Keep a mirror in step | |
| 141 | − | ||
| 142 | − | The repository's **Settings → Mirroring** shows the link: its state, | |
| 143 | − | whether GitHub is answering, and when it last synced. **Sync now** copies | |
| 144 | − | at once. **Take over**, **Hand back**, **Move to g1t** and **Remove** are | |
| 145 | − | there too: see [mirroring](/guides/mirroring/). A standby mirror stops, | |
| 146 | − | keeping its copy, when: | |
| 147 | − | ||
| 148 | − | - the repository is deleted on GitHub, | |
| 149 | − | - the app is uninstalled from its GitHub account, or | |
| 150 | − | - a workspace owner removes that GitHub account from the workspace. | |
| 151 | − | ||
| 152 | − | If GitHub stops letting the app see a repository, its settings say so; add | |
| 153 | − | it back to the installation on GitHub to carry on. Renaming or | |
| 154 | − | transferring a repository on GitHub keeps the mirror working. | |
| 155 | − | ||
| 156 | 130 | ## What g1t's GitHub App can do | |
| 157 | 131 | ||
| 158 | 132 | | Permission | Access | Why | | |
| 159 | 133 | | --- | --- | --- | | |
| 160 | − | | Contents | Read and write | Read: clone the repositories you choose. Write: to push for **Move to g1t**, and to hand a takeover back. | | |
| 134 | + | | Contents | Read | Clone the repositories you choose. | | |
| 161 | 135 | | Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. | | |
| 162 | 136 | | Issues | Read | Copy issues when you ask. | | |
| 163 | − | | 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. | | |
| 164 | − | | Commit statuses | Read and write | While g1t runs a mirror's workflows, report each run and deploy on the commit on GitHub. Without it, the mirror's settings say so and nothing is reported. See [Checks on GitHub](/guides/mirroring/#checks-on-github). | | |
| 165 | 137 | | Email addresses (account) | Read | Find your verified email when you sign in. | | |
| 166 | 138 | ||
| 167 | 139 | The app only ever sees the repositories you or your organization's owners | |
| ⋯ | |||
| 178 | 150 | GitHub afterwards, which authorizes g1t again. | |
| 179 | 151 | - **Stop g1t reading repositories**: uninstall the app in GitHub under | |
| 180 | 152 | **Settings → Applications → Installed GitHub Apps** (for an organization, | |
| 181 | − | its settings). Mirrors stop; the copies on g1t stay. | |
| 153 | + | its settings). Repositories already imported stay on g1t. | |
| 182 | 154 | - **Change which repositories it sees**: **Choose repositories on GitHub** | |
| 183 | 155 | beside the account on the import page. | |
| 184 | 156 | ||
| 185 | 157 | ## The webhook | |
| 186 | 158 | ||
| 187 | − | GitHub tells g1t about pushes, installations and repository changes at | |
| 159 | + | GitHub tells g1t about installations and repository changes at | |
| 188 | 160 | `POST https://api.g1t.sh/hooks/github`. g1t checks each delivery's | |
| 189 | 161 | `X-Hub-Signature-256` against the app's webhook secret, ignores a delivery | |
| 190 | 162 | id it has already seen (`X-GitHub-Delivery`), and answers `202` before | |
Comments
Sign in to comment.